From 716e1bc494d80032b8763e1578ddf95117f2cd83 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 9 Feb 2025 21:01:26 +0000 Subject: [PATCH 01/29] feat: command for downloading all members of PDS to local directory Signed-off-by: JWaters02 --- .../src/extend/MainframeInteraction.ts | 10 +++ .../src/profiles/ZoweExplorerZosmfApi.ts | 7 ++ packages/zowe-explorer/package.json | 61 ++++++++++++++++ packages/zowe-explorer/package.nls.json | 4 + .../src/trees/dataset/DatasetActions.ts | 73 +++++++++++++++++++ .../src/trees/dataset/DatasetInit.ts | 6 ++ .../src/trees/dataset/DatasetUtils.ts | 32 +++++++- 7 files changed, 192 insertions(+), 1 deletion(-) diff --git a/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts b/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts index a120bea0a8..534b1cfc51 100644 --- a/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts +++ b/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts @@ -234,6 +234,16 @@ export namespace MainframeInteraction { */ getContents(dataSetName: string, options?: zosfiles.IDownloadSingleOptions): Promise; + /** + * Retrieve all members of a partitioned data set and save them to a directory. + * + * @param {string} dataSetName + * @param {string} directoryPath + * @param {zosfiles.IDownloadOptions} [options] + * @returns {Promise} + */ + downloadAllMembers(dataSetName: string, options?: zosfiles.IDownloadOptions): Promise; + /** * Uploads a given buffer as the contents of a file to a data set or member. * diff --git a/packages/zowe-explorer-api/src/profiles/ZoweExplorerZosmfApi.ts b/packages/zowe-explorer-api/src/profiles/ZoweExplorerZosmfApi.ts index 2aa8285d74..ba1a79b984 100644 --- a/packages/zowe-explorer-api/src/profiles/ZoweExplorerZosmfApi.ts +++ b/packages/zowe-explorer-api/src/profiles/ZoweExplorerZosmfApi.ts @@ -263,6 +263,13 @@ export namespace ZoweExplorerZosmf { }); } + public downloadAllMembers(dataSetName: string, options?: zosfiles.IDownloadOptions): Promise { + return zosfiles.Download.allMembers(this.getSession(), dataSetName, { + responseTimeout: this.profile?.profile?.responseTimeout, + ...options, + }); + } + public uploadFromBuffer(buffer: Buffer, dataSetName: string, options?: zosfiles.IUploadOptions): Promise { return zosfiles.Upload.bufferToDataSet(this.getSession(), buffer, dataSetName, { responseTimeout: this.profile?.profile?.responseTimeout, diff --git a/packages/zowe-explorer/package.json b/packages/zowe-explorer/package.json index 5542a39cc0..89146c8dea 100644 --- a/packages/zowe-explorer/package.json +++ b/packages/zowe-explorer/package.json @@ -331,6 +331,26 @@ "title": "%uploadDialog%", "category": "Zowe Explorer" }, + { + "command": "zowe.ds.downloadDataset", + "title": "%downloadDataset%", + "category": "Zowe Explorer" + }, + { + "command": "zowe.ds.downloadMember", + "title": "%downloadMember%", + "category": "Zowe Explorer" + }, + { + "command": "zowe.ds.downloadAllDatasets", + "title": "%downloadAllDatasets%", + "category": "Zowe Explorer" + }, + { + "command": "zowe.ds.downloadAllMembers", + "title": "%downloadAllMembers%", + "category": "Zowe Explorer" + }, { "command": "zowe.ds.editDataSet", "title": "%editDataSet%", @@ -1124,6 +1144,31 @@ "command": "zowe.ds.renameDataSet", "group": "099_zowe_dsModification@2" }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^session.*/ && !listMultiSelection", + "command": "zowe.ds.downloadAllDatasets", + "group": "099_zowe_dsModification@3" + }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^ds.*/ && !listMultiSelection", + "command": "zowe.ds.downloadDataset", + "group": "099_zowe_dsModification@3" + }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/ && !listMultiSelection", + "command": "zowe.ds.downloadAllMembers", + "group": "099_zowe_dsModification@3" + }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^migr.*/ && !listMultiSelection", + "command": "zowe.ds.downloadAllMembers", + "group": "099_zowe_dsModification@3" + }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^member.*/ && !listMultiSelection", + "command": "zowe.ds.downloadMember", + "group": "099_zowe_dsModification@3" + }, { "when": "view == zowe.ds.explorer && viewItem =~ /^ds.*/", "command": "zowe.ds.deleteDataset", @@ -1582,6 +1627,22 @@ "command": "zowe.ds.sortBy", "when": "never" }, + { + "command": "zowe.ds.downloadAllDatasets", + "when": "never" + }, + { + "command": "zowe.ds.downloadDataset", + "when": "never" + }, + { + "command": "zowe.ds.downloadAllMembers", + "when": "never" + }, + { + "command": "zowe.ds.downloadMember", + "when": "never" + }, { "command": "zowe.jobs.startPolling", "when": "never" diff --git a/packages/zowe-explorer/package.nls.json b/packages/zowe-explorer/package.nls.json index e54af70ce9..81a57d5bd4 100644 --- a/packages/zowe-explorer/package.nls.json +++ b/packages/zowe-explorer/package.nls.json @@ -134,6 +134,10 @@ "jobs.filterBy": "Filter Jobs...", "ds.filterBy": "Filter PDS Members...", "ds.sortBy": "Sort PDS Members...", + "downloadAllDatasets": "Download All Data Sets...", + "downloadDataset": "Download Data Set...", + "downloadAllMembers": "Download All Members...", + "downloadMember": "Download Member...", "issueUnixCmd": "Issue Unix Command", "selectForCompare": "Select for Compare", "copyName": "Copy Name", diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index b9a5b516f2..cc4c11ead9 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -506,6 +506,79 @@ export class DatasetActions { } } + /** + * Downloads all the members of a PDS + * TODO (@JWaters02): Implement path history + * TODO (@JWaters02): Figure out extensionMap on the downloadAllMembers API + */ + public static async downloadAllMembers(node: IZoweDatasetTreeNode): Promise { + ZoweLogger.trace("dataset.actions.downloadDataset called."); + + const profile = node.getProfile(); + await Profiles.getInstance().checkCurrentProfile(profile); + if (Profiles.getInstance().validProfile === Validation.ValidationType.INVALID) { + Gui.errorMessage(DatasetActions.localizedStrings.profileInvalid); + return; + } + + // Placeholder for history-related bits + const historyItems: vscode.QuickPickItem[] = []; // Add history items here + + historyItems.push({ label: vscode.l10n.t("Enter a new file path...") }); + + const quickPickOptions: vscode.QuickPickOptions = { + placeHolder: vscode.l10n.t("Select a download location or enter a new file path"), + ignoreFocusOut: true, + }; + + const selectedOption = await Gui.showQuickPick(historyItems, quickPickOptions); + if (!selectedOption) { + Gui.showMessage(vscode.l10n.t("Operation cancelled")); + return; + } + + let selectedPath: string; + if (selectedOption.label === vscode.l10n.t("Enter a new file path...")) { + const options: vscode.OpenDialogOptions = { + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: vscode.l10n.t("Select Download Location"), + }; + + const downloadPath = await Gui.showOpenDialog(options); + if (!downloadPath || downloadPath.length === 0) { + Gui.showMessage(vscode.l10n.t("Operation cancelled")); + return; + } + + selectedPath = downloadPath[0].fsPath; + // Placeholder for adding the new path to history + } else { + selectedPath = selectedOption.label; + } + + ZoweLogger.info(`Selected download path: ${selectedPath}`); + + try { + const datasetName = node.label as string; + const maxConcurrentRequests = profile.profile?.maxConcurrentRequests || 1; + const extensionMap = await DatasetUtils.getExtensionMap(node); + + const downloadOptions: zosfiles.IDownloadOptions = { + directory: selectedPath, + maxConcurrentRequests, + extensionMap, + overwrite: true, + }; + + await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); + Gui.showMessage(vscode.l10n.t("Dataset downloaded successfully")); + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); + } + } + /** * Deletes nodes from the data set tree & delegates deletion of data sets, members, and profiles * diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts b/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts index be381dd4c7..cf6dca17ae 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts @@ -202,6 +202,12 @@ export class DatasetInit { ) ); + context.subscriptions.push( + vscode.commands.registerCommand("zowe.ds.downloadAllMembers", async (node: IZoweDatasetTreeNode) => + DatasetActions.downloadAllMembers(node) + ) + ); + SharedInit.initSubscribers(context, datasetProvider); return datasetProvider; } diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts b/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts index 1da346d717..6d93ebbe1a 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts @@ -10,7 +10,7 @@ */ import * as vscode from "vscode"; -import { DS_EXTENSION_MAP, Types } from "@zowe/zowe-explorer-api"; +import { DS_EXTENSION_MAP, IZoweDatasetTreeNode, Types } from "@zowe/zowe-explorer-api"; import { Constants } from "../../configuration/Constants"; import { ZoweLogger } from "../../tools/ZoweLogger"; export class DatasetUtils { @@ -104,4 +104,34 @@ export class DatasetUtils { } return null; } + + /** + * Gets a map of file extensions for all members of a PDS to be used for IDownloadOptions. + */ + public static async getExtensionMap(node: IZoweDatasetTreeNode): Promise<{ [key: string]: string }> { + ZoweLogger.trace("dataset.utils.getExtensionMap called."); + const extensionMap: { [key: string]: string } = {}; + const children = await node.getChildren(); + + for (const child of children) { + let extension; + for (const [ext, matches] of DS_EXTENSION_MAP.entries()) { + if (matches.some((match) => (match instanceof RegExp ? match.test(child.label as string) : match === (child.label as string)))) { + extension = ext; + break; + } + } + + if (extension) { + extensionMap[child.label as string] = extension; + } else { + const parentExtension = DatasetUtils.getExtension(node.label as string); + if (parentExtension) { + extensionMap[child.label as string] = parentExtension; + } + } + } + + return extensionMap; + } } From 72877ab88d857d54ecdc8c133287a15f230134d6 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Fri, 14 Feb 2025 10:55:47 +0000 Subject: [PATCH 02/29] Attempt on tests Signed-off-by: JWaters02 --- .../__tests__/__unit__/extension.unit.test.ts | 4 + .../trees/dataset/DatasetActions.unit.test.ts | 146 ++++++++++++++++++ packages/zowe-explorer/l10n/bundle.l10n.json | 90 ++++++----- packages/zowe-explorer/l10n/poeditor.json | 28 ++-- .../src/configuration/Constants.ts | 2 +- .../src/trees/dataset/DatasetActions.ts | 4 +- 6 files changed, 211 insertions(+), 63 deletions(-) diff --git a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts index 9220e057a1..fc41fa82b4 100644 --- a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts @@ -153,6 +153,10 @@ async function createGlobalMocks() { "zowe.ds.allocateLike", "zowe.ds.uploadDialog", "zowe.ds.deleteMember", + "zowe.ds.downloadDataset", + "zowe.ds.downloadAllDatasets", + "zowe.ds.downloadMember", + "zowe.ds.downloadAllMembers", "zowe.ds.editDataSet", "zowe.ds.editMember", "zowe.ds.submitJcl", diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts index 639958b128..e22e940b0a 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts @@ -415,6 +415,152 @@ describe("Dataset Actions Unit Tests - Function refreshPS", () => { }); }); +describe("DatasetActions - downloading functions", () => { + function createBlockMocks() { + const session = createISession(); + const imperativeProfile = createIProfile(); + const zosmfSession = createSessCfgFromArgs(imperativeProfile); + const treeView = createTreeView(); + const datasetSessionNode = createDatasetSessionNode(session, imperativeProfile); + const testDatasetTree = createDatasetTree(datasetSessionNode, treeView); + const mvsApi = createMvsApi(imperativeProfile); + const fetchDsAtUri = jest.spyOn(DatasetFSProvider.instance, "fetchDatasetAtUri").mockImplementation(); + const profileInstance = createInstanceOfProfile(imperativeProfile); + const mockCheckCurrentProfile = jest.fn(); + bindMvsApi(mvsApi); + Object.defineProperty(ProfilesCache, "getProfileSessionWithVscProxy", { value: jest.fn().mockReturnValue(zosmfSession), configurable: true }); + + return { + session, + zosmfSession, + treeView, + imperativeProfile, + datasetSessionNode, + mvsApi, + testDatasetTree, + fetchDsAtUri, + profileInstance, + mockCheckCurrentProfile, + }; + } + + // let getMvsApiSpy: jest.SpyInstance; + // const downloadAllDatasetsMock = jest.fn(); + // const fakeMvsApi = { + // downloadAllDatasets: downloadAllDatasetsMock, + // }; + + // beforeAll(() => { + // getMvsApiSpy = jest.spyOn(ZoweExplorerApiRegister, "getMvsApi").mockReturnValue(fakeMvsApi as any); + // }); + + // afterAll(() => jest.restoreAllMocks()); + + describe("function downloadAllMembers", () => { + it("should download all members successfully with valid profile", async () => { + createGlobalMocks(); + const blockMocks = createBlockMocksShared(); + const node = new ZoweDatasetNode({ + label: "HLQ.TEST.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: blockMocks.datasetSessionNode, + }); + + mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ label: "Enter a new file path..." }); + mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([vscode.Uri.file("C:/Downloads")]); + mocked(DatasetUtils.getExtensionMap).mockResolvedValueOnce({ member1: ".txt", member2: ".c" }); + + await DatasetActions.downloadAllMembers(node); + + expect(mocked(Gui.showMessage)).toHaveBeenCalledWith("Dataset downloaded successfully"); + // expect(getMvsApiSpy).toHaveBeenCalledTimes(1); + }); + + // it("should handle invalid profile", async () => { + // createGlobalMocks(); + // const blockMocks = createBlockMocks(); + // const node = new ZoweDatasetNode({ + // label: "HLQ.TEST.PDS", + // collapsibleState: vscode.TreeItemCollapsibleState.None, + // parentNode: blockMocks.datasetSessionNode, + // profile: blockMocks.imperativeProfile, + // }); + + // mocked(Profiles.getInstance).mockReturnValue(blockMocks.profileInstance); + // Object.defineProperty(Profiles, "getInstance", { + // value: jest.fn(() => { + // return { + // checkCurrentProfile: blockMocks.mockCheckCurrentProfile.mockReturnValueOnce({ + // name: blockMocks.imperativeProfile.name, + // status: "unverified", + // }), + // validProfile: Validation.ValidationType.UNVERIFIED, + // }; + // }), + // }); + + // await DatasetActions.downloadAllMembers(node); + + // expect(mocked(Gui.errorMessage)).toHaveBeenCalledWith(DatasetActions.localizedStrings.profileInvalid); + // }); + + // it("should handle user cancelling quick pick", async () => { + // createGlobalMocks(); + // const blockMocks = createBlockMocks(); + // const node = new ZoweDatasetNode({ + // label: "HLQ.TEST.PDS", + // collapsibleState: vscode.TreeItemCollapsibleState.None, + // parentNode: blockMocks.datasetSessionNode, + // profile: blockMocks.imperativeProfile, + // }); + + // mocked(vscode.window.showQuickPick).mockResolvedValueOnce(undefined); + + // await DatasetActions.downloadAllMembers(node); + + // expect(mocked(Gui.showMessage)).toHaveBeenCalledWith("Operation cancelled"); + // }); + + // it("should handle user cancelling file dialog", async () => { + // createGlobalMocks(); + // const blockMocks = createBlockMocks(); + // const node = new ZoweDatasetNode({ + // label: "HLQ.TEST.PDS", + // collapsibleState: vscode.TreeItemCollapsibleState.None, + // parentNode: blockMocks.datasetSessionNode, + // profile: blockMocks.imperativeProfile, + // }); + + // mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ label: "Enter a new file path..." }); + // mocked(vscode.window.showOpenDialog).mockResolvedValueOnce(undefined); + + // await DatasetActions.downloadAllMembers(node); + + // expect(mocked(Gui.showMessage)).toHaveBeenCalledWith("Operation cancelled"); + // }); + + // it("should handle download failure", async () => { + // createGlobalMocks(); + // const blockMocks = createBlockMocks(); + // const node = new ZoweDatasetNode({ + // label: "HLQ.TEST.PDS", + // collapsibleState: vscode.TreeItemCollapsibleState.None, + // parentNode: blockMocks.datasetSessionNode, + // profile: blockMocks.imperativeProfile, + // }); + + // mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ label: "Enter a new file path..." }); + // mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([vscode.Uri.file("C:/Downloads")]); + // mocked(DatasetUtils.getExtensionMap).mockResolvedValueOnce({ member1: ".txt", member2: ".c" }); + + // await DatasetActions.downloadAllMembers(node); + + // expect(mocked(Gui.errorMessage)).toHaveBeenCalledWith("Failed to download dataset"); + // expect(getMvsApiSpy).toHaveBeenCalledTimes(1); + // }); + }); +}); + describe("Dataset Actions Unit Tests - Function deleteDatasetPrompt", () => { function createBlockMocks(globalMocks) { const testDatasetTree = createDatasetTree(globalMocks.datasetSessionNode, globalMocks.treeView, globalMocks.testFavoritesNode); diff --git a/packages/zowe-explorer/l10n/bundle.l10n.json b/packages/zowe-explorer/l10n/bundle.l10n.json index 22ddd198ad..5e6a4dcd8b 100644 --- a/packages/zowe-explorer/l10n/bundle.l10n.json +++ b/packages/zowe-explorer/l10n/bundle.l10n.json @@ -198,42 +198,6 @@ "Profile auth error": "Profile auth error", "Profile is not authenticated, please log in to continue": "Profile is not authenticated, please log in to continue", "Retrieving response from USS list API": "Retrieving response from USS list API", - "The 'move' function is not implemented for this USS API.": "The 'move' function is not implemented for this USS API.", - "Failed to move {0}/File path": { - "message": "Failed to move {0}", - "comment": [ - "File path" - ] - }, - "Profile does not exist for this file.": "Profile does not exist for this file.", - "Saving USS file...": "Saving USS file...", - "Failed to rename {0}/File path": { - "message": "Failed to rename {0}", - "comment": [ - "File path" - ] - }, - "Failed to delete {0}/File name": { - "message": "Failed to delete {0}", - "comment": [ - "File name" - ] - }, - "No error details given": "No error details given", - "Error fetching destination {0} for paste action: {1}/USS pathError message": { - "message": "Error fetching destination {0} for paste action: {1}", - "comment": [ - "USS path", - "Error message" - ] - }, - "Failed to copy {0} to {1}/Source pathDestination path": { - "message": "Failed to copy {0} to {1}", - "comment": [ - "Source path", - "Destination path" - ] - }, "Downloaded: {0}/Download time": { "message": "Downloaded: {0}", "comment": [ @@ -249,8 +213,6 @@ "Binary": "Binary", "Create a new filter": "Create a new filter", "Failed to move file {0}: {1}": "Failed to move file {0}: {1}", - "Confirm": "Confirm", - "One or more items may be overwritten from this drop operation. Confirm or cancel?": "One or more items may be overwritten from this drop operation. Confirm or cancel?", "Moving USS files...": "Moving USS files...", "Enter a new name for the {0}/Node type": { "message": "Enter a new name for the {0}", @@ -297,6 +259,42 @@ "initializeUSSFavorites.error.buttonRemove": "initializeUSSFavorites.error.buttonRemove", "File does not exist. It may have been deleted.": "File does not exist. It may have been deleted.", "Pulling from Mainframe...": "Pulling from Mainframe...", + "The 'move' function is not implemented for this USS API.": "The 'move' function is not implemented for this USS API.", + "Failed to move {0}/File path": { + "message": "Failed to move {0}", + "comment": [ + "File path" + ] + }, + "Profile does not exist for this file.": "Profile does not exist for this file.", + "Saving USS file...": "Saving USS file...", + "Failed to rename {0}/File path": { + "message": "Failed to rename {0}", + "comment": [ + "File path" + ] + }, + "Failed to delete {0}/File name": { + "message": "Failed to delete {0}", + "comment": [ + "File name" + ] + }, + "No error details given": "No error details given", + "Error fetching destination {0} for paste action: {1}/USS pathError message": { + "message": "Error fetching destination {0} for paste action: {1}", + "comment": [ + "USS path", + "Error message" + ] + }, + "Failed to copy {0} to {1}/Source pathDestination path": { + "message": "Failed to copy {0} to {1}", + "comment": [ + "Source path", + "Destination path" + ] + }, "{0} location/Node type": { "message": "{0} location", "comment": [ @@ -369,6 +367,8 @@ "Name of USS session" ] }, + "Confirm": "Confirm", + "One or more items may be overwritten from this drop operation. Confirm or cancel?": "One or more items may be overwritten from this drop operation. Confirm or cancel?", "Team config file created, refreshing Zowe Explorer.": "Team config file created, refreshing Zowe Explorer.", "Team config file deleted, refreshing Zowe Explorer.": "Team config file deleted, refreshing Zowe Explorer.", "Team config file updated, refreshing Zowe Explorer.": "Team config file updated, refreshing Zowe Explorer.", @@ -509,15 +509,8 @@ "Job name" ] }, - "Are you sure you want to delete the following {0} items?\nThis will permanently remove the following jobs from your system.\n\n{1}/Jobs lengthJob names": { - "message": "Are you sure you want to delete the following {0} items?\nThis will permanently remove the following jobs from your system.\n\n{1}", - "comment": [ - "Jobs length", - "Job names" - ] - }, - "The following jobs were deleted: {0}/Deleted jobs": { - "message": "The following jobs were deleted: {0}", + "The following jobs were deleted: {0}{1}/Deleted jobs": { + "message": "The following jobs were deleted: {0}{1}", "comment": [ "Deleted jobs" ] @@ -584,6 +577,9 @@ "Date Modified": "Date Modified", "User ID": "User ID", "Create a new filter. For example: HLQ.*, HLQ.aaa.bbb, HLQ.ccc.ddd(member)": "Create a new filter. For example: HLQ.*, HLQ.aaa.bbb, HLQ.ccc.ddd(member)", + "Cannot drop a sequential dataset or a partitioned dataset onto another PDS.": "Cannot drop a sequential dataset or a partitioned dataset onto another PDS.", + "Cannot drop a member onto a sequential dataset.": "Cannot drop a member onto a sequential dataset.", + "Moving MVS files...": "Moving MVS files...", "Initializing profiles with data set favorites.": "Initializing profiles with data set favorites.", "No data set favorites found.": "No data set favorites found.", "Error creating data set favorite node: {0} for profile {1}./LabelProfile name": { diff --git a/packages/zowe-explorer/l10n/poeditor.json b/packages/zowe-explorer/l10n/poeditor.json index eabd5ec6cd..5934b6ae6e 100644 --- a/packages/zowe-explorer/l10n/poeditor.json +++ b/packages/zowe-explorer/l10n/poeditor.json @@ -552,22 +552,11 @@ "Profile auth error": "", "Profile is not authenticated, please log in to continue": "", "Retrieving response from USS list API": "", - "The 'move' function is not implemented for this USS API.": "", - "Failed to move {0}": "", - "Profile does not exist for this file.": "", - "Saving USS file...": "", - "Failed to rename {0}": "", - "Failed to delete {0}": "", - "No error details given": "", - "Error fetching destination {0} for paste action: {1}": "", - "Failed to copy {0} to {1}": "", "Downloaded: {0}": "", "Encoding: {0}": "", "Binary": "", "Create a new filter": "", "Failed to move file {0}: {1}": "", - "Confirm": "", - "One or more items may be overwritten from this drop operation. Confirm or cancel?": "", "Moving USS files...": "", "Enter a new name for the {0}": "", "Unable to rename node:": "", @@ -588,6 +577,15 @@ "initializeUSSFavorites.error.buttonRemove": "", "File does not exist. It may have been deleted.": "", "Pulling from Mainframe...": "", + "The 'move' function is not implemented for this USS API.": "", + "Failed to move {0}": "", + "Profile does not exist for this file.": "", + "Saving USS file...": "", + "Failed to rename {0}": "", + "Failed to delete {0}": "", + "No error details given": "", + "Error fetching destination {0} for paste action: {1}": "", + "Failed to copy {0} to {1}": "", "{0} location": "", "Choose a location to create the {0}": "", "Name of file or directory": "", @@ -615,6 +613,8 @@ "Current encoding is {0}": "", "Enter a codepage (e.g., 1047, IBM-1047)": "", "A search must be set for {0} before it can be added to a workspace.": "", + "Confirm": "", + "One or more items may be overwritten from this drop operation. Confirm or cancel?": "", "Team config file created, refreshing Zowe Explorer.": "", "Team config file deleted, refreshing Zowe Explorer.": "", "Team config file updated, refreshing Zowe Explorer.": "", @@ -687,8 +687,7 @@ "Failed to delete job {0}": "", "Are you sure you want to delete the following item?\nThis will permanently remove the following job from your system.\n\n{0}": "", "Job {0} was deleted.": "", - "Are you sure you want to delete the following {0} items?\nThis will permanently remove the following jobs from your system.\n\n{1}": "", - "The following jobs were deleted: {0}": "", + "The following jobs were deleted: {0}{1}": "", "Download single spool operation not implemented by extender. Please contact the extension developer(s).": "", "No spool files found for {0}": "", "Modify Command": "", @@ -715,6 +714,9 @@ "Date Modified": "", "User ID": "", "Create a new filter. For example: HLQ.*, HLQ.aaa.bbb, HLQ.ccc.ddd(member)": "", + "Cannot drop a sequential dataset or a partitioned dataset onto another PDS.": "", + "Cannot drop a member onto a sequential dataset.": "", + "Moving MVS files...": "", "Initializing profiles with data set favorites.": "", "No data set favorites found.": "", "Error creating data set favorite node: {0} for profile {1}.": "", diff --git a/packages/zowe-explorer/src/configuration/Constants.ts b/packages/zowe-explorer/src/configuration/Constants.ts index 581259ae97..03a76d7edf 100644 --- a/packages/zowe-explorer/src/configuration/Constants.ts +++ b/packages/zowe-explorer/src/configuration/Constants.ts @@ -16,7 +16,7 @@ import { imperative, PersistenceSchemaEnum } from "@zowe/zowe-explorer-api"; import type { Profiles } from "./Profiles"; export class Constants { - public static readonly COMMAND_COUNT = 105; + public static readonly COMMAND_COUNT = 106; public static readonly MAX_SEARCH_HISTORY = 5; public static readonly MAX_FILE_HISTORY = 10; public static readonly MS_PER_SEC = 1000; diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index cc4c11ead9..0eda599ee5 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -508,8 +508,8 @@ export class DatasetActions { /** * Downloads all the members of a PDS - * TODO (@JWaters02): Implement path history - * TODO (@JWaters02): Figure out extensionMap on the downloadAllMembers API + * TODO: Implement path history + * TODO: Figure out extensionMap on the downloadAllMembers API */ public static async downloadAllMembers(node: IZoweDatasetTreeNode): Promise { ZoweLogger.trace("dataset.actions.downloadDataset called."); From 08de65025e7fdefbc1c8693907e22c00107f57f6 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 24 May 2025 22:24:44 +0100 Subject: [PATCH 03/29] refactor: smarter attempt at downloadAllMembers Signed-off-by: JWaters02 --- .../src/configuration/Constants.ts | 2 +- .../src/configuration/Definitions.ts | 2 + .../src/trees/dataset/DatasetActions.ts | 207 ++++++++++++++---- .../src/trees/dataset/DatasetUtils.ts | 12 +- 4 files changed, 174 insertions(+), 49 deletions(-) diff --git a/packages/zowe-explorer/src/configuration/Constants.ts b/packages/zowe-explorer/src/configuration/Constants.ts index 4e5c03228c..336f37bda7 100644 --- a/packages/zowe-explorer/src/configuration/Constants.ts +++ b/packages/zowe-explorer/src/configuration/Constants.ts @@ -16,7 +16,7 @@ import { imperative, PersistenceSchemaEnum } from "@zowe/zowe-explorer-api"; import type { Profiles } from "./Profiles"; export class Constants { - public static readonly COMMAND_COUNT = 113; + public static readonly COMMAND_COUNT = 114; public static readonly MAX_SEARCH_HISTORY = 5; public static readonly MAX_FILE_HISTORY = 10; public static readonly MAX_DISPLAYED_DELETE_NAMES = 10; diff --git a/packages/zowe-explorer/src/configuration/Definitions.ts b/packages/zowe-explorer/src/configuration/Definitions.ts index 35ea4d3ddf..c74698ca03 100644 --- a/packages/zowe-explorer/src/configuration/Definitions.ts +++ b/packages/zowe-explorer/src/configuration/Definitions.ts @@ -39,6 +39,7 @@ export namespace Definitions { caseSensitive?: boolean; regex?: boolean; }; + export type DataSetDownloadOptions = {}; export type FavoriteData = { profileName: string; label: string; @@ -155,5 +156,6 @@ export namespace Definitions { SETTINGS_OLD_SETTINGS_MIGRATED = "zowe.settings.oldSettingsMigrated", V1_MIGRATION_STATUS = "zowe.v1MigrationStatus", DS_SEARCH_OPTIONS = "zowe.dsSearchOptions", + DOWNLOAD_OPTIONS = "zowe.downloadOptions", } } diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index 81fa3e1ebd..c21c177216 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -509,8 +509,6 @@ export class DatasetActions { /** * Downloads all the members of a PDS - * TODO: Implement path history - * TODO: Figure out extensionMap on the downloadAllMembers API */ public static async downloadAllMembers(node: IZoweDatasetTreeNode): Promise { ZoweLogger.trace("dataset.actions.downloadDataset called."); @@ -522,62 +520,181 @@ export class DatasetActions { return; } - // Placeholder for history-related bits - const historyItems: vscode.QuickPickItem[] = []; // Add history items here + // Step 1: Show options quick pick + const optionItems: vscode.QuickPickItem[] = [ + { + label: vscode.l10n.t("Overwrite"), + description: vscode.l10n.t("Overwrite existing files"), + picked: true, + }, + { + label: vscode.l10n.t("Preserve Original Letter Case"), + description: vscode.l10n.t("Preserve original letter case of member names"), + picked: true, + }, + { + label: vscode.l10n.t("Binary"), + description: vscode.l10n.t("Download members as binary files"), + picked: false, + }, + { + label: vscode.l10n.t("Record"), + description: vscode.l10n.t("Download members as record files"), + picked: false, + }, + ]; - historyItems.push({ label: vscode.l10n.t("Enter a new file path...") }); + const optionsQuickPick = Gui.createQuickPick(); + optionsQuickPick.title = vscode.l10n.t("Download Options"); + optionsQuickPick.placeholder = vscode.l10n.t("Select download options"); + optionsQuickPick.ignoreFocusOut = true; + optionsQuickPick.canSelectMany = true; + optionsQuickPick.items = optionItems; + optionsQuickPick.selectedItems = optionItems.filter((item) => item.picked); + + const selectedOptions: vscode.QuickPickItem[] = await new Promise((resolve) => { + optionsQuickPick.onDidAccept(() => { + resolve(Array.from(optionsQuickPick.selectedItems)); + optionsQuickPick.hide(); + }); + optionsQuickPick.onDidHide(() => { + resolve([]); + }); + optionsQuickPick.show(); + }); + optionsQuickPick.dispose(); - const quickPickOptions: vscode.QuickPickOptions = { - placeHolder: vscode.l10n.t("Select a download location or enter a new file path"), - ignoreFocusOut: true, + if (!selectedOptions || selectedOptions.length === 0) { + Gui.showMessage(DatasetActions.localizedStrings.opCancelled); + return; + } + + // Step 2: Ask for download location + const dialogOptions: vscode.OpenDialogOptions = { + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: vscode.l10n.t("Select Download Location"), }; - const selectedOption = await Gui.showQuickPick(historyItems, quickPickOptions); - if (!selectedOption) { + const downloadPath = await Gui.showOpenDialog(dialogOptions); + if (!downloadPath || downloadPath.length === 0) { Gui.showMessage(vscode.l10n.t("Operation cancelled")); return; } - let selectedPath: string; - if (selectedOption.label === vscode.l10n.t("Enter a new file path...")) { - const options: vscode.OpenDialogOptions = { - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - openLabel: vscode.l10n.t("Select Download Location"), - }; + const selectedPath = downloadPath[0].fsPath; + ZoweLogger.info(`Selected download path: ${selectedPath}`); - const downloadPath = await Gui.showOpenDialog(options); - if (!downloadPath || downloadPath.length === 0) { - Gui.showMessage(vscode.l10n.t("Operation cancelled")); - return; - } + // Step 3: Map selected options to download options + const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); + const overwrite = getOption("Overwrite"); + const preserveOriginalLetterCase = getOption("Preserve Original Letter Case"); + const binary = getOption("Binary"); + const record = getOption("Record"); - selectedPath = downloadPath[0].fsPath; - // Placeholder for adding the new path to history - } else { - selectedPath = selectedOption.label; - } + // Step 4: Show progress bar and report download progress + await Gui.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t("Downloading all members"), + cancellable: true, + }, + async (progress) => { + let realPercentComplete = 0; + const children = await node.getChildren(); + const realTotalEntries = children.length; + const task: imperative.ITaskWithStatus = { + set percentComplete(value: number) { + realPercentComplete = value; + // eslint-disable-next-line no-magic-numbers + Gui.reportProgress(progress, realTotalEntries, Math.floor((value * realTotalEntries) / 100), ""); + }, + get percentComplete(): number { + return realPercentComplete; + }, + statusMessage: "", + stageName: 0, // TaskStage.IN_PROGRESS + }; - ZoweLogger.info(`Selected download path: ${selectedPath}`); + try { + const datasetName = node.label as string; + const maxConcurrentRequests = profile.profile?.maxConcurrentRequests || 1; + const extension = DatasetUtils.getExtension(datasetName); + + // Get all members + const members = children.filter((child) => SharedContext.isDsMember(child)); + let completed = 0; + let failed = 0; + + for (const memberNode of members) { + const memberName = memberNode.label as string; + // Build the local file path for this member + const fileName = preserveOriginalLetterCase ? memberName : memberName.toUpperCase(); + const filePath = path.join(selectedPath, `${fileName}${extension ? extension : ""}`); + + // If overwrite, check and delete file if exists + if (overwrite) { + try { + await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + try { + await vscode.workspace.fs.delete(vscode.Uri.file(filePath), { recursive: false, useTrash: false }); + } catch (deleteErr) { + failed++; + ZoweLogger.error(`Failed to delete existing file for overwrite: ${filePath} - ${deleteErr.message as string}`); + continue; // Skip download for this member + } + } catch { + // File does not exist, nothing to delete + } + } else { + // If not overwrite and file exists, skip + try { + await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + ZoweLogger.info(`File exists and overwrite is false, skipping: ${filePath}`); + completed++; + // eslint-disable-next-line no-magic-numbers + task.percentComplete = (completed / realTotalEntries) * 100; + continue; + } catch { + // File does not exist, continue + } + } + } - try { - const datasetName = node.label as string; - const maxConcurrentRequests = profile.profile?.maxConcurrentRequests || 1; - const extensionMap = await DatasetUtils.getExtensionMap(node); - - const downloadOptions: zosfiles.IDownloadOptions = { - directory: selectedPath, - maxConcurrentRequests, - extensionMap, - overwrite: true, - }; + // Download the members + try { + const downloadOptions: zosfiles.IDownloadOptions = { + directory: selectedPath, + maxConcurrentRequests, + extension, + preserveOriginalLetterCase, + binary, + record, + task, + }; - await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); - Gui.showMessage(vscode.l10n.t("Dataset downloaded successfully")); - } catch (e) { - await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); - } + await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); + completed++; + } catch (downloadErr) { + failed++; + } + + // eslint-disable-next-line no-magic-numbers + task.percentComplete = (completed / realTotalEntries) * 100; + + if (failed === 0) { + Gui.showMessage(vscode.l10n.t("Dataset downloaded successfully")); + } else { + Gui.errorMessage( + vscode.l10n.t({ message: "Downloaded with {0} failures", args: [failed], comment: ["Number of failed downloads"] }) + ); + } + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); + } + } + ); } /** diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts b/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts index 016b8b552c..e29a4d2dc7 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts @@ -115,19 +115,25 @@ export class DatasetUtils { for (const child of children) { let extension; + const label = child.label as string; for (const [ext, matches] of DS_EXTENSION_MAP.entries()) { - if (matches.some((match) => (match instanceof RegExp ? match.test(child.label as string) : match === (child.label as string)))) { + if (ext === ".c") { + // Special case for ".c" extension, skip the following logic + // As it's not unique enough and would other match on anything containing "C" + continue; + } + if (matches.some((match) => (match instanceof RegExp ? match.test(label) : label.includes(match)))) { extension = ext; break; } } if (extension) { - extensionMap[child.label as string] = extension; + extensionMap[label] = extension.startsWith(".") ? extension.slice(1) : extension; } else { const parentExtension = DatasetUtils.getExtension(node.label as string); if (parentExtension) { - extensionMap[child.label as string] = parentExtension; + extensionMap[label] = parentExtension.startsWith(".") ? parentExtension.slice(1) : parentExtension; } } } From ab2535a2a752dd9ed6ee71ee753f3b71a6c211c2 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 25 May 2025 13:36:51 +0100 Subject: [PATCH 04/29] store download options in local storage Signed-off-by: JWaters02 --- .../src/configuration/Definitions.ts | 8 +++- .../src/tools/ZoweLocalStorage.ts | 1 + .../src/trees/dataset/DatasetActions.ts | 41 ++++++++++--------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/zowe-explorer/src/configuration/Definitions.ts b/packages/zowe-explorer/src/configuration/Definitions.ts index c74698ca03..33dc10110c 100644 --- a/packages/zowe-explorer/src/configuration/Definitions.ts +++ b/packages/zowe-explorer/src/configuration/Definitions.ts @@ -39,7 +39,11 @@ export namespace Definitions { caseSensitive?: boolean; regex?: boolean; }; - export type DataSetDownloadOptions = {}; + export type DataSetDownloadOptions = { + overwrite?: boolean; + preserveCase?: boolean; + binary?: boolean; + }; export type FavoriteData = { profileName: string; label: string; @@ -156,6 +160,6 @@ export namespace Definitions { SETTINGS_OLD_SETTINGS_MIGRATED = "zowe.settings.oldSettingsMigrated", V1_MIGRATION_STATUS = "zowe.v1MigrationStatus", DS_SEARCH_OPTIONS = "zowe.dsSearchOptions", - DOWNLOAD_OPTIONS = "zowe.downloadOptions", + DS_DOWNLOAD_OPTIONS = "zowe.dsDownloadOptions", } } diff --git a/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts b/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts index 964733a06a..fd8f0f22e3 100644 --- a/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts +++ b/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts @@ -66,6 +66,7 @@ export class LocalStorageAccess extends ZoweLocalStorage { [Definitions.LocalStorageKey.SETTINGS_OLD_SETTINGS_MIGRATED]: StorageAccessLevel.Read, [Definitions.LocalStorageKey.ENCODING_HISTORY]: StorageAccessLevel.Read | StorageAccessLevel.Write, [Definitions.LocalStorageKey.DS_SEARCH_OPTIONS]: StorageAccessLevel.Read | StorageAccessLevel.Write, + [Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS]: StorageAccessLevel.Read | StorageAccessLevel.Write, [PersistenceSchemaEnum.Dataset]: StorageAccessLevel.Read | StorageAccessLevel.Write, [PersistenceSchemaEnum.USS]: StorageAccessLevel.Read | StorageAccessLevel.Write, [PersistenceSchemaEnum.Job]: StorageAccessLevel.Read | StorageAccessLevel.Write, diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index c21c177216..c85245756c 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -39,6 +39,7 @@ import { FilterItem } from "../../management/FilterManagement"; import { AuthUtils } from "../../utils/AuthUtils"; import { Definitions } from "../../configuration/Definitions"; import { TreeViewUtils } from "../../utils/TreeViewUtils"; +import { ZoweLocalStorage } from "../../tools/ZoweLocalStorage"; export class DatasetActions { public static typeEnum: zosfiles.CreateDataSetTypeEnum; @@ -520,27 +521,29 @@ export class DatasetActions { return; } + const dataSetDownloadOptions: Definitions.DataSetDownloadOptions = + ZoweLocalStorage.getValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS) ?? {}; + + dataSetDownloadOptions.overwrite ??= true; + dataSetDownloadOptions.preserveCase ??= true; + dataSetDownloadOptions.binary ??= false; + // Step 1: Show options quick pick const optionItems: vscode.QuickPickItem[] = [ { label: vscode.l10n.t("Overwrite"), description: vscode.l10n.t("Overwrite existing files"), - picked: true, + picked: dataSetDownloadOptions.overwrite, }, { label: vscode.l10n.t("Preserve Original Letter Case"), description: vscode.l10n.t("Preserve original letter case of member names"), - picked: true, + picked: dataSetDownloadOptions.preserveCase, }, { label: vscode.l10n.t("Binary"), description: vscode.l10n.t("Download members as binary files"), - picked: false, - }, - { - label: vscode.l10n.t("Record"), - description: vscode.l10n.t("Download members as record files"), - picked: false, + picked: dataSetDownloadOptions.binary, }, ]; @@ -564,11 +567,6 @@ export class DatasetActions { }); optionsQuickPick.dispose(); - if (!selectedOptions || selectedOptions.length === 0) { - Gui.showMessage(DatasetActions.localizedStrings.opCancelled); - return; - } - // Step 2: Ask for download location const dialogOptions: vscode.OpenDialogOptions = { canSelectFiles: false, @@ -579,7 +577,7 @@ export class DatasetActions { const downloadPath = await Gui.showOpenDialog(dialogOptions); if (!downloadPath || downloadPath.length === 0) { - Gui.showMessage(vscode.l10n.t("Operation cancelled")); + Gui.showMessage(DatasetActions.localizedStrings.opCancelled); return; } @@ -591,7 +589,11 @@ export class DatasetActions { const overwrite = getOption("Overwrite"); const preserveOriginalLetterCase = getOption("Preserve Original Letter Case"); const binary = getOption("Binary"); - const record = getOption("Record"); + + dataSetDownloadOptions.overwrite = overwrite; + dataSetDownloadOptions.preserveCase = preserveOriginalLetterCase; + dataSetDownloadOptions.binary = binary; + await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_SEARCH_OPTIONS, dataSetDownloadOptions); // Step 4: Show progress bar and report download progress await Gui.withProgress( @@ -630,10 +632,12 @@ export class DatasetActions { for (const memberNode of members) { const memberName = memberNode.label as string; // Build the local file path for this member + // preserveOriginalLetterCase option is not used in the downloadAllMembers API when directory is specified so handle it here const fileName = preserveOriginalLetterCase ? memberName : memberName.toUpperCase(); const filePath = path.join(selectedPath, `${fileName}${extension ? extension : ""}`); // If overwrite, check and delete file if exists + // This exists because the downloadAllMembers API does not handle overwriting files if (overwrite) { try { await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); @@ -668,21 +672,18 @@ export class DatasetActions { directory: selectedPath, maxConcurrentRequests, extension, - preserveOriginalLetterCase, binary, - record, task, }; await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); completed++; + // eslint-disable-next-line no-magic-numbers + task.percentComplete = (completed / realTotalEntries) * 100; } catch (downloadErr) { failed++; } - // eslint-disable-next-line no-magic-numbers - task.percentComplete = (completed / realTotalEntries) * 100; - if (failed === 0) { Gui.showMessage(vscode.l10n.t("Dataset downloaded successfully")); } else { From f87c6b5308f8b55ebaddc8839b8dbbb77111ed90 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Thu, 5 Jun 2025 15:41:36 +0100 Subject: [PATCH 05/29] WIP Signed-off-by: JWaters02 --- .../trees/dataset/DatasetActions.unit.test.ts | 148 +------------- .../trees/dataset/DatasetInit.unit.test.ts | 4 + .../src/configuration/Constants.ts | 2 +- .../src/trees/dataset/DatasetActions.ts | 180 ++++++++++++++++-- .../src/trees/dataset/DatasetInit.ts | 4 + 5 files changed, 174 insertions(+), 164 deletions(-) diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts index 1ee51d160a..77877a44ff 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts @@ -418,152 +418,6 @@ describe("Dataset Actions Unit Tests - Function refreshPS", () => { }); }); -describe("DatasetActions - downloading functions", () => { - function createBlockMocks() { - const session = createISession(); - const imperativeProfile = createIProfile(); - const zosmfSession = createSessCfgFromArgs(imperativeProfile); - const treeView = createTreeView(); - const datasetSessionNode = createDatasetSessionNode(session, imperativeProfile); - const testDatasetTree = createDatasetTree(datasetSessionNode, treeView); - const mvsApi = createMvsApi(imperativeProfile); - const fetchDsAtUri = jest.spyOn(DatasetFSProvider.instance, "fetchDatasetAtUri").mockImplementation(); - const profileInstance = createInstanceOfProfile(imperativeProfile); - const mockCheckCurrentProfile = jest.fn(); - bindMvsApi(mvsApi); - Object.defineProperty(ProfilesCache, "getProfileSessionWithVscProxy", { value: jest.fn().mockReturnValue(zosmfSession), configurable: true }); - - return { - session, - zosmfSession, - treeView, - imperativeProfile, - datasetSessionNode, - mvsApi, - testDatasetTree, - fetchDsAtUri, - profileInstance, - mockCheckCurrentProfile, - }; - } - - // let getMvsApiSpy: jest.SpyInstance; - // const downloadAllDatasetsMock = jest.fn(); - // const fakeMvsApi = { - // downloadAllDatasets: downloadAllDatasetsMock, - // }; - - // beforeAll(() => { - // getMvsApiSpy = jest.spyOn(ZoweExplorerApiRegister, "getMvsApi").mockReturnValue(fakeMvsApi as any); - // }); - - // afterAll(() => jest.restoreAllMocks()); - - describe("function downloadAllMembers", () => { - it("should download all members successfully with valid profile", async () => { - createGlobalMocks(); - const blockMocks = createBlockMocksShared(); - const node = new ZoweDatasetNode({ - label: "HLQ.TEST.PDS", - collapsibleState: vscode.TreeItemCollapsibleState.None, - parentNode: blockMocks.datasetSessionNode, - }); - - mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ label: "Enter a new file path..." }); - mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([vscode.Uri.file("C:/Downloads")]); - mocked(DatasetUtils.getExtensionMap).mockResolvedValueOnce({ member1: ".txt", member2: ".c" }); - - await DatasetActions.downloadAllMembers(node); - - expect(mocked(Gui.showMessage)).toHaveBeenCalledWith("Dataset downloaded successfully"); - // expect(getMvsApiSpy).toHaveBeenCalledTimes(1); - }); - - // it("should handle invalid profile", async () => { - // createGlobalMocks(); - // const blockMocks = createBlockMocks(); - // const node = new ZoweDatasetNode({ - // label: "HLQ.TEST.PDS", - // collapsibleState: vscode.TreeItemCollapsibleState.None, - // parentNode: blockMocks.datasetSessionNode, - // profile: blockMocks.imperativeProfile, - // }); - - // mocked(Profiles.getInstance).mockReturnValue(blockMocks.profileInstance); - // Object.defineProperty(Profiles, "getInstance", { - // value: jest.fn(() => { - // return { - // checkCurrentProfile: blockMocks.mockCheckCurrentProfile.mockReturnValueOnce({ - // name: blockMocks.imperativeProfile.name, - // status: "unverified", - // }), - // validProfile: Validation.ValidationType.UNVERIFIED, - // }; - // }), - // }); - - // await DatasetActions.downloadAllMembers(node); - - // expect(mocked(Gui.errorMessage)).toHaveBeenCalledWith(DatasetActions.localizedStrings.profileInvalid); - // }); - - // it("should handle user cancelling quick pick", async () => { - // createGlobalMocks(); - // const blockMocks = createBlockMocks(); - // const node = new ZoweDatasetNode({ - // label: "HLQ.TEST.PDS", - // collapsibleState: vscode.TreeItemCollapsibleState.None, - // parentNode: blockMocks.datasetSessionNode, - // profile: blockMocks.imperativeProfile, - // }); - - // mocked(vscode.window.showQuickPick).mockResolvedValueOnce(undefined); - - // await DatasetActions.downloadAllMembers(node); - - // expect(mocked(Gui.showMessage)).toHaveBeenCalledWith("Operation cancelled"); - // }); - - // it("should handle user cancelling file dialog", async () => { - // createGlobalMocks(); - // const blockMocks = createBlockMocks(); - // const node = new ZoweDatasetNode({ - // label: "HLQ.TEST.PDS", - // collapsibleState: vscode.TreeItemCollapsibleState.None, - // parentNode: blockMocks.datasetSessionNode, - // profile: blockMocks.imperativeProfile, - // }); - - // mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ label: "Enter a new file path..." }); - // mocked(vscode.window.showOpenDialog).mockResolvedValueOnce(undefined); - - // await DatasetActions.downloadAllMembers(node); - - // expect(mocked(Gui.showMessage)).toHaveBeenCalledWith("Operation cancelled"); - // }); - - // it("should handle download failure", async () => { - // createGlobalMocks(); - // const blockMocks = createBlockMocks(); - // const node = new ZoweDatasetNode({ - // label: "HLQ.TEST.PDS", - // collapsibleState: vscode.TreeItemCollapsibleState.None, - // parentNode: blockMocks.datasetSessionNode, - // profile: blockMocks.imperativeProfile, - // }); - - // mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ label: "Enter a new file path..." }); - // mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([vscode.Uri.file("C:/Downloads")]); - // mocked(DatasetUtils.getExtensionMap).mockResolvedValueOnce({ member1: ".txt", member2: ".c" }); - - // await DatasetActions.downloadAllMembers(node); - - // expect(mocked(Gui.errorMessage)).toHaveBeenCalledWith("Failed to download dataset"); - // expect(getMvsApiSpy).toHaveBeenCalledTimes(1); - // }); - }); -}); - describe("Dataset Actions Unit Tests - Function deleteDatasetPrompt", () => { function createBlockMocks(globalMocks) { const testDatasetTree = createDatasetTree(globalMocks.datasetSessionNode, globalMocks.treeView, globalMocks.testFavoritesNode); @@ -3150,3 +3004,5 @@ describe("Dataset Actions Unit Tests - function copyName", () => { expect(mocked(vscode.env.clipboard.writeText)).toHaveBeenCalledWith("A.VSAM"); }); }); + +describe("DatasetActions - downloading functions", () => {}); diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts index 2f28e6d1bb..686047b6ae 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts @@ -113,6 +113,10 @@ describe("Test src/dataset/extension", () => { name: "zowe.ds.uploadDialog", mock: [{ spy: jest.spyOn(DatasetActions, "uploadDialog"), arg: [test.value, dsProvider] }], }, + { + name: "zowe.ds.downloadAllMembers", + mock: [{ spy: jest.spyOn(DatasetActions, "downloadAllMembers"), arg: [test.value] }], + }, { name: "zowe.ds.deleteMember", mock: [{ spy: jest.spyOn(DatasetActions, "deleteDatasetPrompt"), arg: [dsProvider, test.value] }], diff --git a/packages/zowe-explorer/src/configuration/Constants.ts b/packages/zowe-explorer/src/configuration/Constants.ts index 336f37bda7..9a7cf7d493 100644 --- a/packages/zowe-explorer/src/configuration/Constants.ts +++ b/packages/zowe-explorer/src/configuration/Constants.ts @@ -16,7 +16,7 @@ import { imperative, PersistenceSchemaEnum } from "@zowe/zowe-explorer-api"; import type { Profiles } from "./Profiles"; export class Constants { - public static readonly COMMAND_COUNT = 114; + public static readonly COMMAND_COUNT = 118; public static readonly MAX_SEARCH_HISTORY = 5; public static readonly MAX_FILE_HISTORY = 10; public static readonly MAX_DISPLAYED_DELETE_NAMES = 10; diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index c85245756c..6ae4934b48 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -633,7 +633,7 @@ export class DatasetActions { const memberName = memberNode.label as string; // Build the local file path for this member // preserveOriginalLetterCase option is not used in the downloadAllMembers API when directory is specified so handle it here - const fileName = preserveOriginalLetterCase ? memberName : memberName.toUpperCase(); + const fileName = preserveOriginalLetterCase ? memberName : memberName.toLowerCase(); const filePath = path.join(selectedPath, `${fileName}${extension ? extension : ""}`); // If overwrite, check and delete file if exists @@ -664,24 +664,24 @@ export class DatasetActions { // File does not exist, continue } } - } - // Download the members - try { - const downloadOptions: zosfiles.IDownloadOptions = { - directory: selectedPath, - maxConcurrentRequests, - extension, - binary, - task, - }; + // Download the members + try { + const downloadOptions: zosfiles.IDownloadOptions = { + file: filePath, + maxConcurrentRequests, + extension, + binary, + task, + }; - await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); - completed++; - // eslint-disable-next-line no-magic-numbers - task.percentComplete = (completed / realTotalEntries) * 100; - } catch (downloadErr) { - failed++; + await ZoweExplorerApiRegister.getMvsApi(profile).getContents(datasetName, downloadOptions); + completed++; + // eslint-disable-next-line no-magic-numbers + task.percentComplete = (completed / realTotalEntries) * 100; + } catch (downloadErr) { + failed++; + } } if (failed === 0) { @@ -698,6 +698,152 @@ export class DatasetActions { ); } + /** + * Downloads a member + * + */ + public static async downloadMember(node: IZoweDatasetTreeNode): Promise { + ZoweLogger.trace("dataset.actions.downloadMember called."); + const profile = node.getProfile(); + await Profiles.getInstance().checkCurrentProfile(profile); + if (Profiles.getInstance().validProfile === Validation.ValidationType.INVALID) { + Gui.errorMessage(DatasetActions.localizedStrings.profileInvalid); + return; + } + + // Step 1: Show options quick pick (reuse from downloadAllMembers) + const dataSetDownloadOptions: Definitions.DataSetDownloadOptions = + ZoweLocalStorage.getValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS) ?? {}; + + dataSetDownloadOptions.overwrite ??= true; + dataSetDownloadOptions.preserveCase ??= true; + dataSetDownloadOptions.binary ??= false; + + const optionItems: vscode.QuickPickItem[] = [ + { + label: vscode.l10n.t("Overwrite"), + description: vscode.l10n.t("Overwrite existing files"), + picked: dataSetDownloadOptions.overwrite, + }, + { + label: vscode.l10n.t("Preserve Original Letter Case"), + description: vscode.l10n.t("Preserve original letter case of member names"), + picked: dataSetDownloadOptions.preserveCase, + }, + { + label: vscode.l10n.t("Binary"), + description: vscode.l10n.t("Download members as binary files"), + picked: dataSetDownloadOptions.binary, + }, + ]; + + const optionsQuickPick = Gui.createQuickPick(); + optionsQuickPick.title = vscode.l10n.t("Download Options"); + optionsQuickPick.placeholder = vscode.l10n.t("Select download options"); + optionsQuickPick.ignoreFocusOut = true; + optionsQuickPick.canSelectMany = true; + optionsQuickPick.items = optionItems; + optionsQuickPick.selectedItems = optionItems.filter((item) => item.picked); + + const selectedOptions: vscode.QuickPickItem[] = await new Promise((resolve) => { + optionsQuickPick.onDidAccept(() => { + resolve(Array.from(optionsQuickPick.selectedItems)); + optionsQuickPick.hide(); + }); + optionsQuickPick.onDidHide(() => { + resolve([]); + }); + optionsQuickPick.show(); + }); + optionsQuickPick.dispose(); + + // Step 2: Ask for download location + const dialogOptions: vscode.OpenDialogOptions = { + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: vscode.l10n.t("Select Download Location"), + }; + + const downloadPath = await Gui.showOpenDialog(dialogOptions); + if (!downloadPath || downloadPath.length === 0) { + Gui.showMessage(DatasetActions.localizedStrings.opCancelled); + return; + } + + const selectedPath = downloadPath[0].fsPath; + ZoweLogger.info(`Selected download path: ${selectedPath}`); + + // Step 3: Map selected options to download options + const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); + const overwrite = getOption("Overwrite"); + const preserveOriginalLetterCase = getOption("Preserve Original Letter Case"); + const binary = getOption("Binary"); + + dataSetDownloadOptions.overwrite = overwrite; + dataSetDownloadOptions.preserveCase = preserveOriginalLetterCase; + dataSetDownloadOptions.binary = binary; + await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_SEARCH_OPTIONS, dataSetDownloadOptions); + + // Step 4: Download the member with progress + await Gui.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t("Downloading member"), + cancellable: false, + }, + async () => { + try { + // Build file path + const parent = node.getParent(); + const dsName = parent.getLabel() as string; + const memberName = node.getLabel() as string; + const extension = DatasetUtils.getExtension(dsName); + const fileName = preserveOriginalLetterCase ? memberName : memberName.toUpperCase(); + const filePath = path.join(selectedPath, `${fileName}${extension ? extension : ""}`); + + // Handle overwrite logic + if (overwrite) { + try { + await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + try { + await vscode.workspace.fs.delete(vscode.Uri.file(filePath), { recursive: false, useTrash: false }); + } catch (deleteErr) { + ZoweLogger.error(`Failed to delete existing file for overwrite: ${filePath} - ${deleteErr.message as string}`); + Gui.errorMessage(vscode.l10n.t("Failed to delete existing file for overwrite: {0}", filePath)); + return; + } + } catch { + // File does not exist, nothing to delete + } + } else { + try { + await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + ZoweLogger.info(`File exists and overwrite is false, skipping: ${filePath}`); + Gui.showMessage(vscode.l10n.t("File already exists and overwrite is disabled.")); + return; + } catch { + // File does not exist, continue + } + } + + // Download the member + const mvsApi = ZoweExplorerApiRegister.getMvsApi(profile); + const downloadOptions: zosfiles.IDownloadOptions = { + file: filePath, + binary, + extension, + responseTimeout: profile?.profile?.responseTimeout, + }; + await mvsApi.getContents(dsName, downloadOptions); + Gui.showMessage(vscode.l10n.t("Member downloaded successfully")); + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile }); + } + } + ); + } + /** * Deletes nodes from the data set tree & delegates deletion of data sets, members, and profiles * diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts b/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts index 367c7f656a..9d42acc20e 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts @@ -210,6 +210,10 @@ export class DatasetInit { ) ); + context.subscriptions.push( + vscode.commands.registerCommand("zowe.ds.downloadMember", async (node: IZoweDatasetTreeNode) => DatasetActions.downloadMember(node)) + ); + SharedInit.initSubscribers(context, datasetProvider); return datasetProvider; } From 42fcb20bc5ef4a83ac7c78cbde8405c0619680de Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 23 Aug 2025 14:48:07 +0100 Subject: [PATCH 06/29] wip: downloading Signed-off-by: JWaters02 --- .../src/configuration/Definitions.ts | 2 + .../src/trees/dataset/DatasetActions.ts | 172 ++++++++++-------- .../src/trees/dataset/DatasetUtils.ts | 11 +- 3 files changed, 104 insertions(+), 81 deletions(-) diff --git a/packages/zowe-explorer/src/configuration/Definitions.ts b/packages/zowe-explorer/src/configuration/Definitions.ts index 26205d8d7e..2d6880d9d6 100644 --- a/packages/zowe-explorer/src/configuration/Definitions.ts +++ b/packages/zowe-explorer/src/configuration/Definitions.ts @@ -43,6 +43,8 @@ export namespace Definitions { overwrite?: boolean; preserveCase?: boolean; binary?: boolean; + record?: boolean; + selectedPath?: vscode.Uri; }; export type FavoriteData = { profileName: string; diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index 9d857d34d3..96ac980792 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -632,6 +632,8 @@ export class DatasetActions { dataSetDownloadOptions.overwrite ??= true; dataSetDownloadOptions.preserveCase ??= true; dataSetDownloadOptions.binary ??= false; + dataSetDownloadOptions.record ??= false; + dataSetDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); // Step 1: Show options quick pick const optionItems: vscode.QuickPickItem[] = [ @@ -650,6 +652,11 @@ export class DatasetActions { description: vscode.l10n.t("Download members as binary files"), picked: dataSetDownloadOptions.binary, }, + { + label: vscode.l10n.t("Record"), + description: vscode.l10n.t("Download the file content in record mode"), + picked: dataSetDownloadOptions.record, + }, ]; const optionsQuickPick = Gui.createQuickPick(); @@ -678,6 +685,7 @@ export class DatasetActions { canSelectFolders: true, canSelectMany: false, openLabel: vscode.l10n.t("Select Download Location"), + defaultUri: dataSetDownloadOptions.selectedPath, }; const downloadPath = await Gui.showOpenDialog(dialogOptions); @@ -694,10 +702,13 @@ export class DatasetActions { const overwrite = getOption("Overwrite"); const preserveOriginalLetterCase = getOption("Preserve Original Letter Case"); const binary = getOption("Binary"); + const record = getOption("Record"); dataSetDownloadOptions.overwrite = overwrite; dataSetDownloadOptions.preserveCase = preserveOriginalLetterCase; dataSetDownloadOptions.binary = binary; + dataSetDownloadOptions.record = record; + dataSetDownloadOptions.selectedPath = vscode.Uri.file(selectedPath); await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_SEARCH_OPTIONS, dataSetDownloadOptions); // Step 4: Show progress bar and report download progress @@ -727,7 +738,7 @@ export class DatasetActions { try { const datasetName = node.label as string; const maxConcurrentRequests = profile.profile?.maxConcurrentRequests || 1; - const extension = DatasetUtils.getExtension(datasetName); + const extensionMap = await DatasetUtils.getExtensionMap(node, preserveOriginalLetterCase); // Get all members const members = children.filter((child) => SharedContext.isDsMember(child)); @@ -736,16 +747,20 @@ export class DatasetActions { for (const memberNode of members) { const memberName = memberNode.label as string; - // Build the local file path for this member - // preserveOriginalLetterCase option is not used in the downloadAllMembers API when directory is specified so handle it here - const fileName = preserveOriginalLetterCase ? memberName : memberName.toLowerCase(); - const filePath = path.join(selectedPath, `${fileName}${extension ? extension : ""}`); + let filePath = path.join(selectedPath, `${memberName}`); // If overwrite, check and delete file if exists - // This exists because the downloadAllMembers API does not handle overwriting files + // Do it here because the downloadAllMembers API does not handle overwriting files if (overwrite) { try { - await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + // Have to do this because stat is case sensitive (Win+Max case-insensitive but Linux is not) + // and member names can be either all upper or all lower based on if preserve case was set when + // downloading previously + const found = await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + if (!found) { + filePath = path.join(selectedPath, `${memberName.toLowerCase()}`); + await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + } try { await vscode.workspace.fs.delete(vscode.Uri.file(filePath), { recursive: false, useTrash: false }); } catch (deleteErr) { @@ -769,24 +784,26 @@ export class DatasetActions { // File does not exist, continue } } + } - // Download the members - try { - const downloadOptions: zosfiles.IDownloadOptions = { - file: filePath, - maxConcurrentRequests, - extension, - binary, - task, - }; + // Download the members + try { + const downloadOptions: zosfiles.IDownloadOptions = { + directory: selectedPath, + maxConcurrentRequests, + preserveOriginalLetterCase: dataSetDownloadOptions.preserveCase, + extensionMap, + binary, + record, + task, + }; - await ZoweExplorerApiRegister.getMvsApi(profile).getContents(datasetName, downloadOptions); - completed++; - // eslint-disable-next-line no-magic-numbers - task.percentComplete = (completed / realTotalEntries) * 100; - } catch (downloadErr) { - failed++; - } + await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); + completed++; + // eslint-disable-next-line no-magic-numbers + task.percentComplete = (completed / realTotalEntries) * 100; + } catch (downloadErr) { + failed++; } if (failed === 0) { @@ -805,7 +822,6 @@ export class DatasetActions { /** * Downloads a member - * */ public static async downloadMember(node: IZoweDatasetTreeNode): Promise { ZoweLogger.trace("dataset.actions.downloadMember called."); @@ -823,6 +839,7 @@ export class DatasetActions { dataSetDownloadOptions.overwrite ??= true; dataSetDownloadOptions.preserveCase ??= true; dataSetDownloadOptions.binary ??= false; + dataSetDownloadOptions.record ??= false; const optionItems: vscode.QuickPickItem[] = [ { @@ -840,6 +857,11 @@ export class DatasetActions { description: vscode.l10n.t("Download members as binary files"), picked: dataSetDownloadOptions.binary, }, + { + label: vscode.l10n.t("Record"), + description: vscode.l10n.t("Download the file content in record mode"), + picked: dataSetDownloadOptions.record, + }, ]; const optionsQuickPick = Gui.createQuickPick(); @@ -884,69 +906,63 @@ export class DatasetActions { const overwrite = getOption("Overwrite"); const preserveOriginalLetterCase = getOption("Preserve Original Letter Case"); const binary = getOption("Binary"); + const record = getOption("Record"); dataSetDownloadOptions.overwrite = overwrite; dataSetDownloadOptions.preserveCase = preserveOriginalLetterCase; dataSetDownloadOptions.binary = binary; - await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_SEARCH_OPTIONS, dataSetDownloadOptions); + dataSetDownloadOptions.record = record; + await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS, dataSetDownloadOptions); - // Step 4: Download the member with progress - await Gui.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t("Downloading member"), - cancellable: false, - }, - async () => { + // Step 4: Download the member + try { + // Build file path + const parent = node.getParent(); + const dsName = parent.getLabel() as string; + const memberName = node.getLabel() as string; + const extension = DatasetUtils.getExtension(dsName); + const fileName = preserveOriginalLetterCase ? memberName : memberName.toUpperCase(); + const filePath = path.join(selectedPath, `${fileName}${extension ? extension : ""}`); + + // Handle overwrite logic + if (overwrite) { try { - // Build file path - const parent = node.getParent(); - const dsName = parent.getLabel() as string; - const memberName = node.getLabel() as string; - const extension = DatasetUtils.getExtension(dsName); - const fileName = preserveOriginalLetterCase ? memberName : memberName.toUpperCase(); - const filePath = path.join(selectedPath, `${fileName}${extension ? extension : ""}`); - - // Handle overwrite logic - if (overwrite) { - try { - await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); - try { - await vscode.workspace.fs.delete(vscode.Uri.file(filePath), { recursive: false, useTrash: false }); - } catch (deleteErr) { - ZoweLogger.error(`Failed to delete existing file for overwrite: ${filePath} - ${deleteErr.message as string}`); - Gui.errorMessage(vscode.l10n.t("Failed to delete existing file for overwrite: {0}", filePath)); - return; - } - } catch { - // File does not exist, nothing to delete - } - } else { - try { - await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); - ZoweLogger.info(`File exists and overwrite is false, skipping: ${filePath}`); - Gui.showMessage(vscode.l10n.t("File already exists and overwrite is disabled.")); - return; - } catch { - // File does not exist, continue - } + await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + try { + await vscode.workspace.fs.delete(vscode.Uri.file(filePath), { recursive: false, useTrash: false }); + } catch (deleteErr) { + ZoweLogger.error(`Failed to delete existing file for overwrite: ${filePath} - ${deleteErr.message as string}`); + Gui.errorMessage(vscode.l10n.t("Failed to delete existing file for overwrite: {0}", filePath)); + return; } - - // Download the member - const mvsApi = ZoweExplorerApiRegister.getMvsApi(profile); - const downloadOptions: zosfiles.IDownloadOptions = { - file: filePath, - binary, - extension, - responseTimeout: profile?.profile?.responseTimeout, - }; - await mvsApi.getContents(dsName, downloadOptions); - Gui.showMessage(vscode.l10n.t("Member downloaded successfully")); - } catch (e) { - await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile }); + } catch { + // File does not exist, nothing to delete + } + } else { + try { + await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + ZoweLogger.info(`File exists and overwrite is false, skipping: ${filePath}`); + Gui.showMessage(vscode.l10n.t("File already exists and overwrite is disabled.")); + return; + } catch { + // File does not exist, continue } } - ); + + // Download the member + const mvsApi = ZoweExplorerApiRegister.getMvsApi(profile); + const downloadOptions: zosfiles.IDownloadOptions = { + file: filePath, + binary, + record, + extension, + responseTimeout: profile?.profile?.responseTimeout, + }; + await mvsApi.getContents(dsName, downloadOptions); + Gui.showMessage(vscode.l10n.t("Member downloaded successfully")); + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile }); + } } /** diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts b/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts index b2b27fb7a6..b2038407b3 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts @@ -161,18 +161,18 @@ export class DatasetUtils { /** * Gets a map of file extensions for all members of a PDS to be used for IDownloadOptions. */ - public static async getExtensionMap(node: IZoweDatasetTreeNode): Promise<{ [key: string]: string }> { + public static async getExtensionMap(node: IZoweDatasetTreeNode, preserveCase: boolean): Promise<{ [key: string]: string }> { ZoweLogger.trace("dataset.utils.getExtensionMap called."); const extensionMap: { [key: string]: string } = {}; const children = await node.getChildren(); for (const child of children) { let extension; - const label = child.label as string; + let label = child.label as string; for (const [ext, matches] of DS_EXTENSION_MAP.entries()) { if (ext === ".c") { // Special case for ".c" extension, skip the following logic - // As it's not unique enough and would other match on anything containing "C" + // As it's not unique enough and would otherwise match on anything containing "C" continue; } if (matches.some((match) => (match instanceof RegExp ? match.test(label) : label.includes(match)))) { @@ -181,9 +181,14 @@ export class DatasetUtils { } } + if (!preserveCase) { + label = label.toLowerCase(); + } + if (extension) { extensionMap[label] = extension.startsWith(".") ? extension.slice(1) : extension; } else { + // Fall back to just using the PDS name as extension const parentExtension = DatasetUtils.getExtension(node.label as string); if (parentExtension) { extensionMap[label] = parentExtension.startsWith(".") ? parentExtension.slice(1) : parentExtension; From 7ea6ff2ec2065baf642b508ad7779415cfd02f88 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 23 Aug 2025 16:28:11 +0100 Subject: [PATCH 07/29] wip: downloading Signed-off-by: JWaters02 --- .../src/configuration/Definitions.ts | 1 + .../src/trees/dataset/DatasetActions.ts | 132 +++++++----------- 2 files changed, 54 insertions(+), 79 deletions(-) diff --git a/packages/zowe-explorer/src/configuration/Definitions.ts b/packages/zowe-explorer/src/configuration/Definitions.ts index 2d6880d9d6..8ad0c46254 100644 --- a/packages/zowe-explorer/src/configuration/Definitions.ts +++ b/packages/zowe-explorer/src/configuration/Definitions.ts @@ -41,6 +41,7 @@ export namespace Definitions { }; export type DataSetDownloadOptions = { overwrite?: boolean; + generateDirectory?: boolean; preserveCase?: boolean; binary?: boolean; record?: boolean; diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index ae341b626f..b054004c51 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -626,11 +626,18 @@ export class DatasetActions { return; } + const children = await node.getChildren(); + if (children.length === 0) { + Gui.showMessage(vscode.l10n.t("The selected data set has no members to download.")); + return; + } + const dataSetDownloadOptions: Definitions.DataSetDownloadOptions = ZoweLocalStorage.getValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS) ?? {}; dataSetDownloadOptions.overwrite ??= true; - dataSetDownloadOptions.preserveCase ??= true; + dataSetDownloadOptions.generateDirectory ??= false; + dataSetDownloadOptions.preserveCase ??= false; dataSetDownloadOptions.binary ??= false; dataSetDownloadOptions.record ??= false; dataSetDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); @@ -642,9 +649,14 @@ export class DatasetActions { description: vscode.l10n.t("Overwrite existing files"), picked: dataSetDownloadOptions.overwrite, }, + { + label: vscode.l10n.t("Generate Directory Structure"), + description: vscode.l10n.t("Generates sub-folders based on the data set name"), + picked: dataSetDownloadOptions.overwrite, + }, { label: vscode.l10n.t("Preserve Original Letter Case"), - description: vscode.l10n.t("Preserve original letter case of member names"), + description: vscode.l10n.t("If this and the above option are checked, names will be downloaded in all upper case"), picked: dataSetDownloadOptions.preserveCase, }, { @@ -668,17 +680,30 @@ export class DatasetActions { optionsQuickPick.selectedItems = optionItems.filter((item) => item.picked); const selectedOptions: vscode.QuickPickItem[] = await new Promise((resolve) => { + let wasAccepted = false; + optionsQuickPick.onDidAccept(() => { + wasAccepted = true; resolve(Array.from(optionsQuickPick.selectedItems)); optionsQuickPick.hide(); }); + optionsQuickPick.onDidHide(() => { - resolve([]); + if (!wasAccepted) { + resolve(null); + } }); + optionsQuickPick.show(); }); optionsQuickPick.dispose(); + // Do this instead of checking for length because unchecking all options is a valid choice + if (selectedOptions === null) { + Gui.showMessage(DatasetActions.localizedStrings.opCancelled); + return; + } + // Step 2: Ask for download location const dialogOptions: vscode.OpenDialogOptions = { canSelectFiles: false, @@ -700,18 +725,20 @@ export class DatasetActions { // Step 3: Map selected options to download options const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); const overwrite = getOption("Overwrite"); + const generateDirectory = getOption("Generate Directory Structure"); const preserveOriginalLetterCase = getOption("Preserve Original Letter Case"); const binary = getOption("Binary"); const record = getOption("Record"); dataSetDownloadOptions.overwrite = overwrite; + dataSetDownloadOptions.generateDirectory = generateDirectory; dataSetDownloadOptions.preserveCase = preserveOriginalLetterCase; dataSetDownloadOptions.binary = binary; dataSetDownloadOptions.record = record; dataSetDownloadOptions.selectedPath = vscode.Uri.file(selectedPath); - await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_SEARCH_OPTIONS, dataSetDownloadOptions); + await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS, dataSetDownloadOptions); - // Step 4: Show progress bar and report download progress + // Step 4: Download all members using the SDK await Gui.withProgress( { location: vscode.ProgressLocation.Notification, @@ -720,7 +747,6 @@ export class DatasetActions { }, async (progress) => { let realPercentComplete = 0; - const children = await node.getChildren(); const realTotalEntries = children.length; const task: imperative.ITaskWithStatus = { set percentComplete(value: number) { @@ -740,79 +766,25 @@ export class DatasetActions { const maxConcurrentRequests = profile.profile?.maxConcurrentRequests || 1; const extensionMap = await DatasetUtils.getExtensionMap(node, preserveOriginalLetterCase); - // Get all members - const members = children.filter((child) => SharedContext.isDsMember(child)); - let completed = 0; - let failed = 0; - - for (const memberNode of members) { - const memberName = memberNode.label as string; - let filePath = path.join(selectedPath, `${memberName}`); - - // If overwrite, check and delete file if exists - // Do it here because the downloadAllMembers API does not handle overwriting files - if (overwrite) { - try { - // Have to do this because stat is case sensitive (Win+Max case-insensitive but Linux is not) - // and member names can be either all upper or all lower based on if preserve case was set when - // downloading previously - const found = await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); - if (!found) { - filePath = path.join(selectedPath, `${memberName.toLowerCase()}`); - await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); - } - try { - await vscode.workspace.fs.delete(vscode.Uri.file(filePath), { recursive: false, useTrash: false }); - } catch (deleteErr) { - failed++; - ZoweLogger.error(`Failed to delete existing file for overwrite: ${filePath} - ${deleteErr.message as string}`); - continue; // Skip download for this member - } - } catch { - // File does not exist, nothing to delete - } - } else { - // If not overwrite and file exists, skip - try { - await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); - ZoweLogger.info(`File exists and overwrite is false, skipping: ${filePath}`); - completed++; - // eslint-disable-next-line no-magic-numbers - task.percentComplete = (completed / realTotalEntries) * 100; - continue; - } catch { - // File does not exist, continue - } - } - } - - // Download the members - try { - const downloadOptions: zosfiles.IDownloadOptions = { - directory: selectedPath, - maxConcurrentRequests, - preserveOriginalLetterCase: dataSetDownloadOptions.preserveCase, - extensionMap, - binary, - record, - task, - }; - - await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); - completed++; - // eslint-disable-next-line no-magic-numbers - task.percentComplete = (completed / realTotalEntries) * 100; - } catch (downloadErr) { - failed++; - } - - if (failed === 0) { - Gui.showMessage(vscode.l10n.t("Dataset downloaded successfully")); - } else { - Gui.errorMessage( - vscode.l10n.t({ message: "Downloaded with {0} failures", args: [failed], comment: ["Number of failed downloads"] }) - ); - } + // Have to do this here because otherwise the data set gets downloaded to VS Code's install directory + const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); + const generatedFileDirectory = path.join(selectedPath, dirsFromDataset); + + const downloadOptions: zosfiles.IDownloadOptions = { + directory: generateDirectory ? generatedFileDirectory : selectedPath, + maxConcurrentRequests, + preserveOriginalLetterCase, + extensionMap, + binary, + record, + overwrite, + task, + responseTimeout: profile?.profile?.responseTimeout, + }; + + await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); + + Gui.showMessage(vscode.l10n.t("Dataset downloaded successfully")); } catch (e) { await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); } @@ -879,6 +851,8 @@ export class DatasetActions { }); optionsQuickPick.onDidHide(() => { resolve([]); + Gui.showMessage(DatasetActions.localizedStrings.opCancelled); + return; }); optionsQuickPick.show(); }); From 8eafadb317df974a25fd913e38d3f288735aff2d Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 24 Aug 2025 15:08:16 +0100 Subject: [PATCH 08/29] wip: downloading Signed-off-by: JWaters02 --- .../src/trees/dataset/DatasetActions.ts | 136 ++++++++++-------- 1 file changed, 80 insertions(+), 56 deletions(-) diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index b054004c51..e76ca80074 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -656,7 +656,7 @@ export class DatasetActions { }, { label: vscode.l10n.t("Preserve Original Letter Case"), - description: vscode.l10n.t("If this and the above option are checked, names will be downloaded in all upper case"), + description: vscode.l10n.t("Specifies if the automatically generated directories and files use the original letter case"), picked: dataSetDownloadOptions.preserveCase, }, { @@ -666,7 +666,7 @@ export class DatasetActions { }, { label: vscode.l10n.t("Record"), - description: vscode.l10n.t("Download the file content in record mode"), + description: vscode.l10n.t("Download members in record mode"), picked: dataSetDownloadOptions.record, }, ]; @@ -738,7 +738,7 @@ export class DatasetActions { dataSetDownloadOptions.selectedPath = vscode.Uri.file(selectedPath); await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS, dataSetDownloadOptions); - // Step 4: Download all members using the SDK + // Step 4: Download all members await Gui.withProgress( { location: vscode.ProgressLocation.Notification, @@ -768,7 +768,9 @@ export class DatasetActions { // Have to do this here because otherwise the data set gets downloaded to VS Code's install directory const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); - const generatedFileDirectory = path.join(selectedPath, dirsFromDataset); + const generatedFileDirectory = preserveOriginalLetterCase + ? path.join(selectedPath, dirsFromDataset.toUpperCase()) + : path.join(selectedPath, dirsFromDataset); const downloadOptions: zosfiles.IDownloadOptions = { directory: generateDirectory ? generatedFileDirectory : selectedPath, @@ -797,6 +799,7 @@ export class DatasetActions { */ public static async downloadMember(node: IZoweDatasetTreeNode): Promise { ZoweLogger.trace("dataset.actions.downloadMember called."); + const profile = node.getProfile(); await Profiles.getInstance().checkCurrentProfile(profile); if (Profiles.getInstance().validProfile === Validation.ValidationType.INVALID) { @@ -804,34 +807,41 @@ export class DatasetActions { return; } - // Step 1: Show options quick pick (reuse from downloadAllMembers) const dataSetDownloadOptions: Definitions.DataSetDownloadOptions = ZoweLocalStorage.getValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS) ?? {}; dataSetDownloadOptions.overwrite ??= true; + dataSetDownloadOptions.generateDirectory ??= false; dataSetDownloadOptions.preserveCase ??= true; dataSetDownloadOptions.binary ??= false; dataSetDownloadOptions.record ??= false; + dataSetDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); + // Step 1: Show options quick pick const optionItems: vscode.QuickPickItem[] = [ { label: vscode.l10n.t("Overwrite"), description: vscode.l10n.t("Overwrite existing files"), picked: dataSetDownloadOptions.overwrite, }, + { + label: vscode.l10n.t("Generate Directory Structure"), + description: vscode.l10n.t("Generates sub-folders based on the data set name"), + picked: dataSetDownloadOptions.generateDirectory, + }, { label: vscode.l10n.t("Preserve Original Letter Case"), - description: vscode.l10n.t("Preserve original letter case of member names"), + description: vscode.l10n.t("Specifies if the automatically generated directories and files use the original letter case"), picked: dataSetDownloadOptions.preserveCase, }, { label: vscode.l10n.t("Binary"), - description: vscode.l10n.t("Download members as binary files"), + description: vscode.l10n.t("Download member as binary file"), picked: dataSetDownloadOptions.binary, }, { label: vscode.l10n.t("Record"), - description: vscode.l10n.t("Download the file content in record mode"), + description: vscode.l10n.t("Download member in record mode"), picked: dataSetDownloadOptions.record, }, ]; @@ -845,25 +855,37 @@ export class DatasetActions { optionsQuickPick.selectedItems = optionItems.filter((item) => item.picked); const selectedOptions: vscode.QuickPickItem[] = await new Promise((resolve) => { + let wasAccepted = false; + optionsQuickPick.onDidAccept(() => { + wasAccepted = true; resolve(Array.from(optionsQuickPick.selectedItems)); optionsQuickPick.hide(); }); + optionsQuickPick.onDidHide(() => { - resolve([]); - Gui.showMessage(DatasetActions.localizedStrings.opCancelled); - return; + if (!wasAccepted) { + resolve(null); + } }); + optionsQuickPick.show(); }); optionsQuickPick.dispose(); + // Do this instead of checking for length because unchecking all options is a valid choice + if (selectedOptions === null) { + Gui.showMessage(DatasetActions.localizedStrings.opCancelled); + return; + } + // Step 2: Ask for download location const dialogOptions: vscode.OpenDialogOptions = { canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: vscode.l10n.t("Select Download Location"), + defaultUri: dataSetDownloadOptions.selectedPath, }; const downloadPath = await Gui.showOpenDialog(dialogOptions); @@ -878,65 +900,67 @@ export class DatasetActions { // Step 3: Map selected options to download options const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); const overwrite = getOption("Overwrite"); + const generateDirectory = getOption("Generate Directory Structure"); const preserveOriginalLetterCase = getOption("Preserve Original Letter Case"); const binary = getOption("Binary"); const record = getOption("Record"); dataSetDownloadOptions.overwrite = overwrite; + dataSetDownloadOptions.generateDirectory = generateDirectory; dataSetDownloadOptions.preserveCase = preserveOriginalLetterCase; dataSetDownloadOptions.binary = binary; dataSetDownloadOptions.record = record; + dataSetDownloadOptions.selectedPath = vscode.Uri.file(selectedPath); await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS, dataSetDownloadOptions); // Step 4: Download the member - try { - // Build file path - const parent = node.getParent(); - const dsName = parent.getLabel() as string; - const memberName = node.getLabel() as string; - const extension = DatasetUtils.getExtension(dsName); - const fileName = preserveOriginalLetterCase ? memberName : memberName.toUpperCase(); - const filePath = path.join(selectedPath, `${fileName}${extension ? extension : ""}`); - - // Handle overwrite logic - if (overwrite) { - try { - await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); - try { - await vscode.workspace.fs.delete(vscode.Uri.file(filePath), { recursive: false, useTrash: false }); - } catch (deleteErr) { - ZoweLogger.error(`Failed to delete existing file for overwrite: ${filePath} - ${deleteErr.message as string}`); - Gui.errorMessage(vscode.l10n.t("Failed to delete existing file for overwrite: {0}", filePath)); - return; - } - } catch { - // File does not exist, nothing to delete - } - } else { + await Gui.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t("Downloading member"), + cancellable: true, + }, + async (_) => { try { - await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); - ZoweLogger.info(`File exists and overwrite is false, skipping: ${filePath}`); - Gui.showMessage(vscode.l10n.t("File already exists and overwrite is disabled.")); - return; - } catch { - // File does not exist, continue + const parent = node.getParent() as IZoweDatasetTreeNode; + const datasetName = parent.getLabel() as string; + const memberName = node.getLabel() as string; + const fullDatasetName = `${datasetName}(${memberName})`; + + const fileName = preserveOriginalLetterCase ? memberName : memberName.toLowerCase(); + + const extensionMap = await DatasetUtils.getExtensionMap(parent, preserveOriginalLetterCase); + const extension = + extensionMap[fileName] || DatasetUtils.getExtension(datasetName) || zosfiles.ZosFilesUtils.DEFAULT_FILE_EXTENSION; + + const filePath = path.join(selectedPath, `${fileName}.${extension}`); + + // Have to do this here because otherwise the data set gets downloaded to VS Code's install directory + const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); + const generatedDir = path.join(selectedPath, dirsFromDataset); + const generatedFilePath = preserveOriginalLetterCase + ? path.join(generatedDir.toUpperCase(), `${fileName}.${extension}`) + : path.join(generatedDir, `${fileName}.${extension}`); + + const downloadOptions: zosfiles.IDownloadSingleOptions = { + file: generateDirectory ? generatedFilePath : filePath, + binary, + record, + // no extension or preserveOriginalLetterCase because it is not used when passing in file option + overwrite, + responseTimeout: profile?.profile?.responseTimeout, + }; + + // TODO: Should we make new function in ZE API called downloadMember that's identical to getContents? + // Just in case getContents changes in the future. They are functionally identical at the moment + await ZoweExplorerApiRegister.getMvsApi(profile).getContents(fullDatasetName, downloadOptions); + + Gui.showMessage(vscode.l10n.t("Member downloaded successfully")); + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); } } - - // Download the member - const mvsApi = ZoweExplorerApiRegister.getMvsApi(profile); - const downloadOptions: zosfiles.IDownloadOptions = { - file: filePath, - binary, - record, - extension, - responseTimeout: profile?.profile?.responseTimeout, - }; - await mvsApi.getContents(dsName, downloadOptions); - Gui.showMessage(vscode.l10n.t("Member downloaded successfully")); - } catch (e) { - await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile }); - } + ); } /** From d230ec0ab7927a96ec427800a9fa8d23af3e43bf Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 24 Aug 2025 16:21:07 +0100 Subject: [PATCH 09/29] wip: add downloadDataSet and move common quickpick to helper Signed-off-by: JWaters02 --- packages/zowe-explorer/package.json | 29 +- packages/zowe-explorer/package.nls.json | 5 +- .../src/configuration/Definitions.ts | 2 + .../src/trees/dataset/DatasetActions.ts | 261 ++++++++---------- .../src/trees/dataset/DatasetInit.ts | 4 + 5 files changed, 127 insertions(+), 174 deletions(-) diff --git a/packages/zowe-explorer/package.json b/packages/zowe-explorer/package.json index 53e7c46bb0..95e2877fd9 100644 --- a/packages/zowe-explorer/package.json +++ b/packages/zowe-explorer/package.json @@ -357,8 +357,8 @@ "category": "Zowe Explorer" }, { - "command": "zowe.ds.downloadDataset", - "title": "%downloadDataset%", + "command": "zowe.ds.downloadDataSet", + "title": "%downloadDataSet%", "category": "Zowe Explorer" }, { @@ -366,11 +366,6 @@ "title": "%downloadMember%", "category": "Zowe Explorer" }, - { - "command": "zowe.ds.downloadAllDatasets", - "title": "%downloadAllDatasets%", - "category": "Zowe Explorer" - }, { "command": "zowe.ds.downloadAllMembers", "title": "%downloadAllMembers%", @@ -1249,14 +1244,9 @@ "command": "zowe.ds.renameDataSet", "group": "099_zowe_dsModification@2" }, - { - "when": "view == zowe.ds.explorer && viewItem =~ /^session.*/ && !listMultiSelection", - "command": "zowe.ds.downloadAllDatasets", - "group": "099_zowe_dsModification@3" - }, { "when": "view == zowe.ds.explorer && viewItem =~ /^ds.*/ && !listMultiSelection", - "command": "zowe.ds.downloadDataset", + "command": "zowe.ds.downloadDataSet", "group": "099_zowe_dsModification@3" }, { @@ -1264,11 +1254,6 @@ "command": "zowe.ds.downloadAllMembers", "group": "099_zowe_dsModification@3" }, - { - "when": "view == zowe.ds.explorer && viewItem =~ /^migr.*/ && !listMultiSelection", - "command": "zowe.ds.downloadAllMembers", - "group": "099_zowe_dsModification@3" - }, { "when": "view == zowe.ds.explorer && viewItem =~ /^member.*/ && !listMultiSelection", "command": "zowe.ds.downloadMember", @@ -1755,11 +1740,7 @@ "when": "never" }, { - "command": "zowe.ds.downloadAllDatasets", - "when": "never" - }, - { - "command": "zowe.ds.downloadDataset", + "command": "zowe.ds.downloadDataSet", "when": "never" }, { @@ -2353,4 +2334,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/zowe-explorer/package.nls.json b/packages/zowe-explorer/package.nls.json index 74b3fe7e70..41f8137c2d 100644 --- a/packages/zowe-explorer/package.nls.json +++ b/packages/zowe-explorer/package.nls.json @@ -171,8 +171,7 @@ "jobs.filterBy": "Filter Jobs...", "ds.filterBy": "Filter PDS Members...", "ds.sortBy": "Sort PDS Members...", - "downloadAllDatasets": "Download All Data Sets...", - "downloadDataset": "Download Data Set...", + "downloadDataSet": "Download Data Set...", "downloadAllMembers": "Download All Members...", "downloadMember": "Download Member...", "issueUnixCmd": "Issue Unix Command", @@ -191,4 +190,4 @@ "zowe.settings.displayReleaseNotes": "Should display the Zowe Explorer release notes after an update.", "zowe.table.maxPinnedRows": "Maximum number of rows that can be pinned to the top of table views.", "zowe.table.hidePinnedRowsWarning": "Hide the warning message when pinning many rows to table views." -} +} \ No newline at end of file diff --git a/packages/zowe-explorer/src/configuration/Definitions.ts b/packages/zowe-explorer/src/configuration/Definitions.ts index 8ad0c46254..8e6228c733 100644 --- a/packages/zowe-explorer/src/configuration/Definitions.ts +++ b/packages/zowe-explorer/src/configuration/Definitions.ts @@ -43,6 +43,8 @@ export namespace Definitions { overwrite?: boolean; generateDirectory?: boolean; preserveCase?: boolean; + // TODO: Do binary and record need to be here? + // API doesn't use seem to them, except for setting normalizeResponseNewLines binary?: boolean; record?: boolean; selectedPath?: vscode.Uri; diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index e76ca80074..b510490154 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -613,25 +613,7 @@ export class DatasetActions { } } - /** - * Downloads all the members of a PDS - */ - public static async downloadAllMembers(node: IZoweDatasetTreeNode): Promise { - ZoweLogger.trace("dataset.actions.downloadDataset called."); - - const profile = node.getProfile(); - await Profiles.getInstance().checkCurrentProfile(profile); - if (Profiles.getInstance().validProfile === Validation.ValidationType.INVALID) { - Gui.errorMessage(DatasetActions.localizedStrings.profileInvalid); - return; - } - - const children = await node.getChildren(); - if (children.length === 0) { - Gui.showMessage(vscode.l10n.t("The selected data set has no members to download.")); - return; - } - + private static async getDataSetDownloadOptions(): Promise { const dataSetDownloadOptions: Definitions.DataSetDownloadOptions = ZoweLocalStorage.getValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS) ?? {}; @@ -642,7 +624,6 @@ export class DatasetActions { dataSetDownloadOptions.record ??= false; dataSetDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); - // Step 1: Show options quick pick const optionItems: vscode.QuickPickItem[] = [ { label: vscode.l10n.t("Overwrite"), @@ -704,7 +685,6 @@ export class DatasetActions { return; } - // Step 2: Ask for download location const dialogOptions: vscode.OpenDialogOptions = { canSelectFiles: false, canSelectFolders: true, @@ -720,25 +700,43 @@ export class DatasetActions { } const selectedPath = downloadPath[0].fsPath; - ZoweLogger.info(`Selected download path: ${selectedPath}`); - - // Step 3: Map selected options to download options const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); - const overwrite = getOption("Overwrite"); - const generateDirectory = getOption("Generate Directory Structure"); - const preserveOriginalLetterCase = getOption("Preserve Original Letter Case"); - const binary = getOption("Binary"); - const record = getOption("Record"); - - dataSetDownloadOptions.overwrite = overwrite; - dataSetDownloadOptions.generateDirectory = generateDirectory; - dataSetDownloadOptions.preserveCase = preserveOriginalLetterCase; - dataSetDownloadOptions.binary = binary; - dataSetDownloadOptions.record = record; + dataSetDownloadOptions.overwrite = getOption("Overwrite"); + dataSetDownloadOptions.generateDirectory = getOption("Generate Directory Structure"); + dataSetDownloadOptions.preserveCase = getOption("Preserve Original Letter Case"); + dataSetDownloadOptions.binary = getOption("Binary"); + dataSetDownloadOptions.record = getOption("Record"); dataSetDownloadOptions.selectedPath = vscode.Uri.file(selectedPath); await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS, dataSetDownloadOptions); - // Step 4: Download all members + return dataSetDownloadOptions; + } + + /** + * Downloads all the members of a PDS + */ + public static async downloadAllMembers(node: IZoweDatasetTreeNode): Promise { + ZoweLogger.trace("dataset.actions.downloadDataset called."); + + const profile = node.getProfile(); + await Profiles.getInstance().checkCurrentProfile(profile); + if (Profiles.getInstance().validProfile === Validation.ValidationType.INVALID) { + Gui.errorMessage(DatasetActions.localizedStrings.profileInvalid); + return; + } + + const children = await node.getChildren(); + if (children.length === 0) { + Gui.showMessage(vscode.l10n.t("The selected data set has no members to download.")); + return; + } + + const dataSetDownloadOptions = await DatasetActions.getDataSetDownloadOptions(); + if (!dataSetDownloadOptions) { + return; + } + const { overwrite, generateDirectory, preserveCase: preserveOriginalLetterCase, binary, record, selectedPath } = dataSetDownloadOptions; + await Gui.withProgress( { location: vscode.ProgressLocation.Notification, @@ -769,11 +767,11 @@ export class DatasetActions { // Have to do this here because otherwise the data set gets downloaded to VS Code's install directory const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); const generatedFileDirectory = preserveOriginalLetterCase - ? path.join(selectedPath, dirsFromDataset.toUpperCase()) - : path.join(selectedPath, dirsFromDataset); + ? path.join(selectedPath.fsPath, dirsFromDataset.toUpperCase()) + : path.join(selectedPath.fsPath, dirsFromDataset); const downloadOptions: zosfiles.IDownloadOptions = { - directory: generateDirectory ? generatedFileDirectory : selectedPath, + directory: generateDirectory ? generatedFileDirectory : selectedPath.fsPath, maxConcurrentRequests, preserveOriginalLetterCase, extensionMap, @@ -807,120 +805,19 @@ export class DatasetActions { return; } - const dataSetDownloadOptions: Definitions.DataSetDownloadOptions = - ZoweLocalStorage.getValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS) ?? {}; - - dataSetDownloadOptions.overwrite ??= true; - dataSetDownloadOptions.generateDirectory ??= false; - dataSetDownloadOptions.preserveCase ??= true; - dataSetDownloadOptions.binary ??= false; - dataSetDownloadOptions.record ??= false; - dataSetDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); - - // Step 1: Show options quick pick - const optionItems: vscode.QuickPickItem[] = [ - { - label: vscode.l10n.t("Overwrite"), - description: vscode.l10n.t("Overwrite existing files"), - picked: dataSetDownloadOptions.overwrite, - }, - { - label: vscode.l10n.t("Generate Directory Structure"), - description: vscode.l10n.t("Generates sub-folders based on the data set name"), - picked: dataSetDownloadOptions.generateDirectory, - }, - { - label: vscode.l10n.t("Preserve Original Letter Case"), - description: vscode.l10n.t("Specifies if the automatically generated directories and files use the original letter case"), - picked: dataSetDownloadOptions.preserveCase, - }, - { - label: vscode.l10n.t("Binary"), - description: vscode.l10n.t("Download member as binary file"), - picked: dataSetDownloadOptions.binary, - }, - { - label: vscode.l10n.t("Record"), - description: vscode.l10n.t("Download member in record mode"), - picked: dataSetDownloadOptions.record, - }, - ]; - - const optionsQuickPick = Gui.createQuickPick(); - optionsQuickPick.title = vscode.l10n.t("Download Options"); - optionsQuickPick.placeholder = vscode.l10n.t("Select download options"); - optionsQuickPick.ignoreFocusOut = true; - optionsQuickPick.canSelectMany = true; - optionsQuickPick.items = optionItems; - optionsQuickPick.selectedItems = optionItems.filter((item) => item.picked); - - const selectedOptions: vscode.QuickPickItem[] = await new Promise((resolve) => { - let wasAccepted = false; - - optionsQuickPick.onDidAccept(() => { - wasAccepted = true; - resolve(Array.from(optionsQuickPick.selectedItems)); - optionsQuickPick.hide(); - }); - - optionsQuickPick.onDidHide(() => { - if (!wasAccepted) { - resolve(null); - } - }); - - optionsQuickPick.show(); - }); - optionsQuickPick.dispose(); - - // Do this instead of checking for length because unchecking all options is a valid choice - if (selectedOptions === null) { - Gui.showMessage(DatasetActions.localizedStrings.opCancelled); + const dataSetDownloadOptions = await DatasetActions.getDataSetDownloadOptions(); + if (!dataSetDownloadOptions) { return; } + const { overwrite, generateDirectory, preserveCase: preserveOriginalLetterCase, binary, record, selectedPath } = dataSetDownloadOptions; - // Step 2: Ask for download location - const dialogOptions: vscode.OpenDialogOptions = { - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - openLabel: vscode.l10n.t("Select Download Location"), - defaultUri: dataSetDownloadOptions.selectedPath, - }; - - const downloadPath = await Gui.showOpenDialog(dialogOptions); - if (!downloadPath || downloadPath.length === 0) { - Gui.showMessage(DatasetActions.localizedStrings.opCancelled); - return; - } - - const selectedPath = downloadPath[0].fsPath; - ZoweLogger.info(`Selected download path: ${selectedPath}`); - - // Step 3: Map selected options to download options - const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); - const overwrite = getOption("Overwrite"); - const generateDirectory = getOption("Generate Directory Structure"); - const preserveOriginalLetterCase = getOption("Preserve Original Letter Case"); - const binary = getOption("Binary"); - const record = getOption("Record"); - - dataSetDownloadOptions.overwrite = overwrite; - dataSetDownloadOptions.generateDirectory = generateDirectory; - dataSetDownloadOptions.preserveCase = preserveOriginalLetterCase; - dataSetDownloadOptions.binary = binary; - dataSetDownloadOptions.record = record; - dataSetDownloadOptions.selectedPath = vscode.Uri.file(selectedPath); - await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS, dataSetDownloadOptions); - - // Step 4: Download the member await Gui.withProgress( { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t("Downloading member"), cancellable: true, }, - async (_) => { + async () => { try { const parent = node.getParent() as IZoweDatasetTreeNode; const datasetName = parent.getLabel() as string; @@ -933,11 +830,11 @@ export class DatasetActions { const extension = extensionMap[fileName] || DatasetUtils.getExtension(datasetName) || zosfiles.ZosFilesUtils.DEFAULT_FILE_EXTENSION; - const filePath = path.join(selectedPath, `${fileName}.${extension}`); + const filePath = path.join(selectedPath.fsPath, `${fileName}.${extension}`); // Have to do this here because otherwise the data set gets downloaded to VS Code's install directory const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); - const generatedDir = path.join(selectedPath, dirsFromDataset); + const generatedDir = path.join(selectedPath.fsPath, dirsFromDataset); const generatedFilePath = preserveOriginalLetterCase ? path.join(generatedDir.toUpperCase(), `${fileName}.${extension}`) : path.join(generatedDir, `${fileName}.${extension}`); @@ -946,7 +843,7 @@ export class DatasetActions { file: generateDirectory ? generatedFilePath : filePath, binary, record, - // no extension or preserveOriginalLetterCase because it is not used when passing in file option + // no extension or preserveOriginalLetterCase because they are not used when passing in file option overwrite, responseTimeout: profile?.profile?.responseTimeout, }; @@ -963,6 +860,76 @@ export class DatasetActions { ); } + /** + * Downloads a sequential data set + */ + public static async downloadDataSet(node: IZoweDatasetTreeNode): Promise { + ZoweLogger.trace("dataset.actions.downloadDataSet called."); + + const profile = node.getProfile(); + await Profiles.getInstance().checkCurrentProfile(profile); + if (Profiles.getInstance().validProfile === Validation.ValidationType.INVALID) { + Gui.errorMessage(DatasetActions.localizedStrings.profileInvalid); + return; + } + + if (SharedContext.isPds(node) || SharedContext.isVsam(node)) { + Gui.showMessage(vscode.l10n.t("This action is only supported for sequential data sets.")); + return; + } + + const dataSetDownloadOptions = await DatasetActions.getDataSetDownloadOptions(); + if (!dataSetDownloadOptions) { + return; + } + const { overwrite, generateDirectory, preserveCase: preserveOriginalLetterCase, binary, record, selectedPath } = dataSetDownloadOptions; + + await Gui.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t("Downloading data set"), + cancellable: true, + }, + async () => { + try { + const datasetName = node.getLabel() as string; + + let fileName = preserveOriginalLetterCase ? datasetName : datasetName.toLowerCase(); + + let targetPath = selectedPath.fsPath; + if (generateDirectory) { + const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); + const generatedDirectory = preserveOriginalLetterCase + ? path.join(selectedPath.fsPath, dirsFromDataset.toUpperCase()) + : path.join(selectedPath.fsPath, dirsFromDataset); + + const pathParts = fileName.split("."); + fileName = pathParts[pathParts.length - 1]; + targetPath = generatedDirectory; + } + + const extension = DatasetUtils.getExtension(datasetName) || zosfiles.ZosFilesUtils.DEFAULT_FILE_EXTENSION; + const filePath = path.join(targetPath, `${fileName}.${extension}`); + + const downloadOptions: zosfiles.IDownloadSingleOptions = { + file: filePath, + binary, + record, + // no extension or preserveOriginalLetterCase because they are not used when passing in file option + overwrite, + responseTimeout: profile?.profile?.responseTimeout, + }; + + await ZoweExplorerApiRegister.getMvsApi(profile).getContents(datasetName, downloadOptions); + + Gui.showMessage(vscode.l10n.t("Data set downloaded successfully")); + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); + } + } + ); + } + /** * Deletes nodes from the data set tree & delegates deletion of data sets, members, and profiles * diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts b/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts index 664a38d7ee..d035a466a8 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts @@ -231,6 +231,10 @@ export class DatasetInit { vscode.commands.registerCommand("zowe.ds.downloadMember", async (node: IZoweDatasetTreeNode) => DatasetActions.downloadMember(node)) ); + context.subscriptions.push( + vscode.commands.registerCommand("zowe.ds.downloadDataSet", async (node: IZoweDatasetTreeNode) => DatasetActions.downloadDataSet(node)) + ); + SharedInit.initSubscribers(context, datasetProvider); return datasetProvider; } From 116d596f28ee7b374692d43c5ec87fe5739d1ef2 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 24 Aug 2025 17:45:12 +0100 Subject: [PATCH 10/29] refactor: reduce code duplication Signed-off-by: JWaters02 --- .../src/configuration/Constants.ts | 1 + .../src/trees/dataset/DatasetActions.ts | 247 +++++++++--------- 2 files changed, 130 insertions(+), 118 deletions(-) diff --git a/packages/zowe-explorer/src/configuration/Constants.ts b/packages/zowe-explorer/src/configuration/Constants.ts index f911e31077..00c71165ee 100644 --- a/packages/zowe-explorer/src/configuration/Constants.ts +++ b/packages/zowe-explorer/src/configuration/Constants.ts @@ -25,6 +25,7 @@ export class Constants { public static readonly STATUS_BAR_TIMEOUT_MS = 5000; public static readonly ACTIVE_JOBS_POLLING_TIMEOUT_MS = 1000; public static readonly MIN_WARN_ACTIVE_JOBS_TO_POLL = 10; + public static readonly MIN_WARN_DOWNLOAD_FILES = 100; public static readonly CONTEXT_PREFIX = "_"; public static readonly FAV_SUFFIX = Constants.CONTEXT_PREFIX + "fav"; public static readonly HOME_SUFFIX = Constants.CONTEXT_PREFIX + "home"; diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index b510490154..08dcdda3ca 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -26,6 +26,7 @@ import { type AttributeInfo, DataSetAttributesProvider, ZosEncoding, + MessageSeverity, } from "@zowe/zowe-explorer-api"; import { ZoweDatasetNode } from "./ZoweDatasetNode"; import { DatasetUtils } from "./DatasetUtils"; @@ -712,6 +713,38 @@ export class DatasetActions { return dataSetDownloadOptions; } + private static generateDirectoryPath(datasetName: string, selectedPath: vscode.Uri, generateDirectory: boolean, preserveCase: boolean): string { + if (!generateDirectory) { + return selectedPath.fsPath; + } + + const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); + return preserveCase ? path.join(selectedPath.fsPath, dirsFromDataset.toUpperCase()) : path.join(selectedPath.fsPath, dirsFromDataset); + } + + private static async executeDownloadWithProgress( + title: string, + downloadFn: (progress?: vscode.Progress<{ message?: string; increment?: number }>) => Promise, + successMessage: string, + node: IZoweDatasetTreeNode + ): Promise { + await Gui.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: true, + }, + async (progress) => { + try { + await downloadFn(progress); + Gui.showMessage(successMessage); + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); + } + } + ); + } + /** * Downloads all the members of a PDS */ @@ -731,18 +764,27 @@ export class DatasetActions { return; } + if (children.length > Constants.MIN_WARN_DOWNLOAD_FILES) { + const proceed = await Gui.showMessage( + vscode.l10n.t( + "This data set has {0} members. Downloading a large number of files may take a long time. Do you want to continue?", + children.length + ), + { severity: MessageSeverity.WARN, items: [vscode.l10n.t("Yes"), vscode.l10n.t("No")], vsCodeOpts: { modal: true } } + ); + if (proceed !== vscode.l10n.t("Yes")) { + return; + } + } + const dataSetDownloadOptions = await DatasetActions.getDataSetDownloadOptions(); if (!dataSetDownloadOptions) { return; } const { overwrite, generateDirectory, preserveCase: preserveOriginalLetterCase, binary, record, selectedPath } = dataSetDownloadOptions; - await Gui.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t("Downloading all members"), - cancellable: true, - }, + await DatasetActions.executeDownloadWithProgress( + vscode.l10n.t("Downloading all members"), async (progress) => { let realPercentComplete = 0; const realTotalEntries = children.length; @@ -759,36 +801,33 @@ export class DatasetActions { stageName: 0, // TaskStage.IN_PROGRESS }; - try { - const datasetName = node.label as string; - const maxConcurrentRequests = profile.profile?.maxConcurrentRequests || 1; - const extensionMap = await DatasetUtils.getExtensionMap(node, preserveOriginalLetterCase); - - // Have to do this here because otherwise the data set gets downloaded to VS Code's install directory - const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); - const generatedFileDirectory = preserveOriginalLetterCase - ? path.join(selectedPath.fsPath, dirsFromDataset.toUpperCase()) - : path.join(selectedPath.fsPath, dirsFromDataset); - - const downloadOptions: zosfiles.IDownloadOptions = { - directory: generateDirectory ? generatedFileDirectory : selectedPath.fsPath, - maxConcurrentRequests, - preserveOriginalLetterCase, - extensionMap, - binary, - record, - overwrite, - task, - responseTimeout: profile?.profile?.responseTimeout, - }; - - await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); - - Gui.showMessage(vscode.l10n.t("Dataset downloaded successfully")); - } catch (e) { - await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); - } - } + const datasetName = node.label as string; + const maxConcurrentRequests = profile.profile?.maxConcurrentRequests || 1; + const extensionMap = await DatasetUtils.getExtensionMap(node, preserveOriginalLetterCase); + + const generatedFileDirectory = DatasetActions.generateDirectoryPath( + datasetName, + selectedPath, + generateDirectory, + preserveOriginalLetterCase + ); + + const downloadOptions: zosfiles.IDownloadOptions = { + directory: generatedFileDirectory, + maxConcurrentRequests, + preserveOriginalLetterCase, + extensionMap, + binary, + record, + overwrite, + task, + responseTimeout: profile?.profile?.responseTimeout, + }; + + await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); + }, + vscode.l10n.t("Dataset downloaded successfully"), + node ); } @@ -811,52 +850,36 @@ export class DatasetActions { } const { overwrite, generateDirectory, preserveCase: preserveOriginalLetterCase, binary, record, selectedPath } = dataSetDownloadOptions; - await Gui.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t("Downloading member"), - cancellable: true, - }, + await DatasetActions.executeDownloadWithProgress( + vscode.l10n.t("Downloading member"), async () => { - try { - const parent = node.getParent() as IZoweDatasetTreeNode; - const datasetName = parent.getLabel() as string; - const memberName = node.getLabel() as string; - const fullDatasetName = `${datasetName}(${memberName})`; - - const fileName = preserveOriginalLetterCase ? memberName : memberName.toLowerCase(); - - const extensionMap = await DatasetUtils.getExtensionMap(parent, preserveOriginalLetterCase); - const extension = - extensionMap[fileName] || DatasetUtils.getExtension(datasetName) || zosfiles.ZosFilesUtils.DEFAULT_FILE_EXTENSION; - - const filePath = path.join(selectedPath.fsPath, `${fileName}.${extension}`); - - // Have to do this here because otherwise the data set gets downloaded to VS Code's install directory - const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); - const generatedDir = path.join(selectedPath.fsPath, dirsFromDataset); - const generatedFilePath = preserveOriginalLetterCase - ? path.join(generatedDir.toUpperCase(), `${fileName}.${extension}`) - : path.join(generatedDir, `${fileName}.${extension}`); - - const downloadOptions: zosfiles.IDownloadSingleOptions = { - file: generateDirectory ? generatedFilePath : filePath, - binary, - record, - // no extension or preserveOriginalLetterCase because they are not used when passing in file option - overwrite, - responseTimeout: profile?.profile?.responseTimeout, - }; - - // TODO: Should we make new function in ZE API called downloadMember that's identical to getContents? - // Just in case getContents changes in the future. They are functionally identical at the moment - await ZoweExplorerApiRegister.getMvsApi(profile).getContents(fullDatasetName, downloadOptions); - - Gui.showMessage(vscode.l10n.t("Member downloaded successfully")); - } catch (e) { - await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); - } - } + const parent = node.getParent() as IZoweDatasetTreeNode; + const datasetName = parent.getLabel() as string; + const memberName = node.getLabel() as string; + const fullDatasetName = `${datasetName}(${memberName})`; + + const fileName = preserveOriginalLetterCase ? memberName : memberName.toLowerCase(); + + const extensionMap = await DatasetUtils.getExtensionMap(parent, preserveOriginalLetterCase); + const extension = extensionMap[fileName] ?? DatasetUtils.getExtension(datasetName) ?? zosfiles.ZosFilesUtils.DEFAULT_FILE_EXTENSION; + + const targetDirectory = generateDirectory + ? DatasetActions.generateDirectoryPath(datasetName, selectedPath, generateDirectory, preserveOriginalLetterCase) + : selectedPath.fsPath; + const filePath = path.join(targetDirectory, `${fileName}.${extension}`); + + const downloadOptions = { + file: filePath, + binary, + record, + overwrite, + responseTimeout: profile?.profile?.responseTimeout, + }; + + await ZoweExplorerApiRegister.getMvsApi(profile).getContents(fullDatasetName, downloadOptions); + }, + vscode.l10n.t("Member downloaded successfully"), + node ); } @@ -884,49 +907,37 @@ export class DatasetActions { } const { overwrite, generateDirectory, preserveCase: preserveOriginalLetterCase, binary, record, selectedPath } = dataSetDownloadOptions; - await Gui.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t("Downloading data set"), - cancellable: true, - }, + await DatasetActions.executeDownloadWithProgress( + vscode.l10n.t("Downloading data set"), async () => { - try { - const datasetName = node.getLabel() as string; - - let fileName = preserveOriginalLetterCase ? datasetName : datasetName.toLowerCase(); - - let targetPath = selectedPath.fsPath; - if (generateDirectory) { - const dirsFromDataset = zosfiles.ZosFilesUtils.getDirsFromDataSet(datasetName); - const generatedDirectory = preserveOriginalLetterCase - ? path.join(selectedPath.fsPath, dirsFromDataset.toUpperCase()) - : path.join(selectedPath.fsPath, dirsFromDataset); + const datasetName = node.getLabel() as string; - const pathParts = fileName.split("."); - fileName = pathParts[pathParts.length - 1]; - targetPath = generatedDirectory; - } + // Extract the last part as filename when generating directories + let fileName = preserveOriginalLetterCase ? datasetName : datasetName.toLowerCase(); + if (generateDirectory) { + const pathParts = fileName.split("."); + fileName = pathParts[pathParts.length - 1]; + } - const extension = DatasetUtils.getExtension(datasetName) || zosfiles.ZosFilesUtils.DEFAULT_FILE_EXTENSION; - const filePath = path.join(targetPath, `${fileName}.${extension}`); + const extension = DatasetUtils.getExtension(datasetName) ?? zosfiles.ZosFilesUtils.DEFAULT_FILE_EXTENSION; - const downloadOptions: zosfiles.IDownloadSingleOptions = { - file: filePath, - binary, - record, - // no extension or preserveOriginalLetterCase because they are not used when passing in file option - overwrite, - responseTimeout: profile?.profile?.responseTimeout, - }; + const targetDirectory = generateDirectory + ? DatasetActions.generateDirectoryPath(datasetName, selectedPath, generateDirectory, preserveOriginalLetterCase) + : selectedPath.fsPath; + const filePath = path.join(targetDirectory, `${fileName}.${extension}`); - await ZoweExplorerApiRegister.getMvsApi(profile).getContents(datasetName, downloadOptions); + const downloadOptions = { + file: filePath, + binary, + record, + overwrite, + responseTimeout: profile?.profile?.responseTimeout, + }; - Gui.showMessage(vscode.l10n.t("Data set downloaded successfully")); - } catch (e) { - await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); - } - } + await ZoweExplorerApiRegister.getMvsApi(profile).getContents(datasetName, downloadOptions); + }, + vscode.l10n.t("Data set downloaded successfully"), + node ); } From fa100fb91dc4792c75dcfabcabb9199485b5e02d Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 30 Aug 2025 19:48:22 +0100 Subject: [PATCH 11/29] feat: download uss files/directories Signed-off-by: JWaters02 --- packages/zowe-explorer/package.json | 30 ++- packages/zowe-explorer/package.nls.json | 2 + .../src/configuration/Definitions.ts | 11 +- .../src/tools/ZoweLocalStorage.ts | 1 + .../src/trees/dataset/DatasetActions.ts | 17 +- .../zowe-explorer/src/trees/uss/USSActions.ts | 249 +++++++++++++++++- .../zowe-explorer/src/trees/uss/USSInit.ts | 10 + 7 files changed, 308 insertions(+), 12 deletions(-) diff --git a/packages/zowe-explorer/package.json b/packages/zowe-explorer/package.json index ab67d1e3bc..8228a6c03d 100644 --- a/packages/zowe-explorer/package.json +++ b/packages/zowe-explorer/package.json @@ -544,6 +544,16 @@ "title": "%uss.uploadDialogWithEncoding%", "category": "Zowe Explorer" }, + { + "command": "zowe.uss.downloadFile", + "title": "%uss.downloadFile%", + "category": "Zowe Explorer" + }, + { + "command": "zowe.uss.downloadDirectory", + "title": "%uss.downloadDirectory%", + "category": "Zowe Explorer" + }, { "command": "zowe.uss.copyUssFile", "title": "%copyFile%", @@ -1009,10 +1019,20 @@ "command": "zowe.uss.renameNode", "group": "099_zowe_ussModification:@3" }, + { + "when": "view == zowe.uss.explorer && viewItem =~ /^textFile|binaryFile/ && !listMultiSelection", + "command": "zowe.uss.downloadFile", + "group": "099_zowe_ussModification:@5" + }, + { + "when": "view == zowe.uss.explorer && viewItem =~ /^directory.*/ && !listMultiSelection", + "command": "zowe.uss.downloadDirectory", + "group": "099_zowe_ussModification:@5" + }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!(ussSession|favorite|.*_fav))/", "command": "zowe.uss.deleteNode", - "group": "099_zowe_ussModification:@4" + "group": "099_zowe_ussModification:@7" }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!.*_fav.*)ussSession.*/ && !listMultiSelection", @@ -1627,6 +1647,14 @@ "command": "zowe.uss.uploadDialog", "when": "never" }, + { + "command": "zowe.uss.downloadFile", + "when": "never" + }, + { + "command": "zowe.uss.downloadDirectory", + "when": "never" + }, { "command": "zowe.uss.copyUssFile", "when": "never" diff --git a/packages/zowe-explorer/package.nls.json b/packages/zowe-explorer/package.nls.json index 41f8137c2d..a0c72e1aa1 100644 --- a/packages/zowe-explorer/package.nls.json +++ b/packages/zowe-explorer/package.nls.json @@ -63,6 +63,8 @@ "uss.uploadDialog": "Upload Files...", "uss.uploadDialogBinary": "Upload Files (Binary)...", "uss.uploadDialogWithEncoding": "Upload Files with Encoding...", + "uss.downloadFile": "Download File...", + "uss.downloadDirectory": "Download Directory...", "uss.text": "Toggle Text", "uss.filterBy": "Search by Directory", "uss.cd": "Go Up One Level", diff --git a/packages/zowe-explorer/src/configuration/Definitions.ts b/packages/zowe-explorer/src/configuration/Definitions.ts index 8e6228c733..931f5622f7 100644 --- a/packages/zowe-explorer/src/configuration/Definitions.ts +++ b/packages/zowe-explorer/src/configuration/Definitions.ts @@ -43,12 +43,18 @@ export namespace Definitions { overwrite?: boolean; generateDirectory?: boolean; preserveCase?: boolean; - // TODO: Do binary and record need to be here? - // API doesn't use seem to them, except for setting normalizeResponseNewLines binary?: boolean; record?: boolean; selectedPath?: vscode.Uri; }; + export type UssDownloadOptions = { + overwrite?: boolean; + generateDirectory?: boolean; + includeHidden?: boolean; + chooseEncoding?: boolean; + encoding?: ZosEncoding; + selectedPath?: vscode.Uri; + }; export type FavoriteData = { profileName: string; label: string; @@ -168,5 +174,6 @@ export namespace Definitions { DS_SEARCH_OPTIONS = "zowe.dsSearchOptions", DISPLAY_RELEASE_NOTES_VERSION = "zowe.displayReleaseNotes", DS_DOWNLOAD_OPTIONS = "zowe.dsDownloadOptions", + USS_DOWNLOAD_OPTIONS = "zowe.ussDownloadOptions", } } diff --git a/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts b/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts index af61e058ce..8235adba65 100644 --- a/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts +++ b/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts @@ -68,6 +68,7 @@ export class LocalStorageAccess extends ZoweLocalStorage { [Definitions.LocalStorageKey.DS_SEARCH_OPTIONS]: StorageAccessLevel.Read | StorageAccessLevel.Write, [Definitions.LocalStorageKey.DISPLAY_RELEASE_NOTES_VERSION]: StorageAccessLevel.Read | StorageAccessLevel.Write, [Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS]: StorageAccessLevel.Read | StorageAccessLevel.Write, + [Definitions.LocalStorageKey.USS_DOWNLOAD_OPTIONS]: StorageAccessLevel.Read | StorageAccessLevel.Write, [PersistenceSchemaEnum.Dataset]: StorageAccessLevel.Read | StorageAccessLevel.Write, [PersistenceSchemaEnum.USS]: StorageAccessLevel.Read | StorageAccessLevel.Write, [PersistenceSchemaEnum.Job]: StorageAccessLevel.Read | StorageAccessLevel.Write, diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index d1bcf459fa..7b3966fd2b 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -626,15 +626,16 @@ export class DatasetActions { dataSetDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); const optionItems: vscode.QuickPickItem[] = [ - { - label: vscode.l10n.t("Overwrite"), - description: vscode.l10n.t("Overwrite existing files"), - picked: dataSetDownloadOptions.overwrite, - }, + // TODO: Re-add overwrite option when enhancement is added to API to support it + // { + // label: vscode.l10n.t("Overwrite"), + // description: vscode.l10n.t("Overwrite existing files"), + // picked: dataSetDownloadOptions.overwrite, + // }, { label: vscode.l10n.t("Generate Directory Structure"), description: vscode.l10n.t("Generates sub-folders based on the data set name"), - picked: dataSetDownloadOptions.overwrite, + picked: dataSetDownloadOptions.generateDirectory, }, { label: vscode.l10n.t("Preserve Original Letter Case"), @@ -759,7 +760,7 @@ export class DatasetActions { } const children = await node.getChildren(); - if (children.length === 0) { + if (!children || children.length === 0) { Gui.showMessage(vscode.l10n.t("The selected data set has no members to download.")); return; } @@ -897,7 +898,7 @@ export class DatasetActions { } if (SharedContext.isPds(node) || SharedContext.isVsam(node)) { - Gui.showMessage(vscode.l10n.t("This action is only supported for sequential data sets.")); + Gui.showMessage(vscode.l10n.t("Cannot download this type of data set.")); return; } diff --git a/packages/zowe-explorer/src/trees/uss/USSActions.ts b/packages/zowe-explorer/src/trees/uss/USSActions.ts index 74238a689e..d9f7550399 100644 --- a/packages/zowe-explorer/src/trees/uss/USSActions.ts +++ b/packages/zowe-explorer/src/trees/uss/USSActions.ts @@ -13,7 +13,7 @@ import * as vscode from "vscode"; import * as fs from "fs"; import * as path from "path"; import * as zosfiles from "@zowe/zos-files-for-zowe-sdk"; -import { Gui, imperative, IZoweUSSTreeNode, Types, ZoweExplorerApiType, ZosEncoding } from "@zowe/zowe-explorer-api"; +import { Gui, imperative, IZoweUSSTreeNode, Types, ZoweExplorerApiType, ZosEncoding, MessageSeverity } from "@zowe/zowe-explorer-api"; import { isBinaryFileSync } from "isbinaryfile"; import { USSAttributeView } from "./USSAttributeView"; import { USSFileStructure } from "./USSFileStructure"; @@ -26,6 +26,8 @@ import { SharedActions } from "../shared/SharedActions"; import { SharedContext } from "../shared/SharedContext"; import { SharedUtils } from "../shared/SharedUtils"; import { AuthUtils } from "../../utils/AuthUtils"; +import { Definitions } from "../../configuration/Definitions"; +import { ZoweLocalStorage } from "../../tools/ZoweLocalStorage"; export class USSActions { /** @@ -312,6 +314,251 @@ export class USSActions { } } + private static async getUssDownloadOptions(node: IZoweUSSTreeNode, isDirectory: boolean = false): Promise { + const ussDownloadOptions: Definitions.UssDownloadOptions = + ZoweLocalStorage.getValue(Definitions.LocalStorageKey.USS_DOWNLOAD_OPTIONS) ?? {}; + + ussDownloadOptions.overwrite ??= false; + ussDownloadOptions.generateDirectory ??= false; + ussDownloadOptions.includeHidden ??= false; + ussDownloadOptions.chooseEncoding ??= false; + ussDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); + + const optionItems: vscode.QuickPickItem[] = [ + { + label: vscode.l10n.t("Generate Directory Structure"), + description: vscode.l10n.t("Generates sub-folders based on the USS path"), + picked: ussDownloadOptions.generateDirectory, + }, + { + label: vscode.l10n.t("Choose Encoding"), + description: ussDownloadOptions.encoding + ? vscode.l10n.t({ + message: "Select specific encoding for files (current: {0})", + args: [ + ussDownloadOptions.encoding.kind === "binary" + ? "binary" + : ussDownloadOptions.encoding.kind === "other" + ? ussDownloadOptions.encoding.codepage || "default" + : ussDownloadOptions.encoding.kind === "text" + ? "text" + : "default", + ], + comment: ["Encoding kind or codepage"], + }) + : vscode.l10n.t("Select specific encoding for files"), + picked: ussDownloadOptions.chooseEncoding, + }, + ]; + + // Add directory-specific options only when downloading directories + if (isDirectory) { + optionItems.splice( + 0, + 0, + { + label: vscode.l10n.t("Overwrite"), + description: vscode.l10n.t("Overwrite existing files when downloading directories"), + picked: ussDownloadOptions.overwrite, + }, + { + label: vscode.l10n.t("Include Hidden Files"), + description: vscode.l10n.t("Include hidden files (those starting with a dot) when downloading directories"), + picked: ussDownloadOptions.includeHidden, + } + ); + } + + const optionsQuickPick = Gui.createQuickPick(); + optionsQuickPick.title = vscode.l10n.t("Download Options"); + optionsQuickPick.placeholder = vscode.l10n.t("Select download options"); + optionsQuickPick.ignoreFocusOut = true; + optionsQuickPick.canSelectMany = true; + optionsQuickPick.items = optionItems; + optionsQuickPick.selectedItems = optionItems.filter((item) => item.picked); + + const selectedOptions: vscode.QuickPickItem[] = await new Promise((resolve) => { + let wasAccepted = false; + + optionsQuickPick.onDidAccept(() => { + wasAccepted = true; + resolve(Array.from(optionsQuickPick.selectedItems)); + optionsQuickPick.hide(); + }); + + optionsQuickPick.onDidHide(() => { + if (!wasAccepted) { + resolve(null); + } + }); + + optionsQuickPick.show(); + }); + optionsQuickPick.dispose(); + + // Do this instead of checking for length because unchecking all options is a valid choice + if (selectedOptions === null) { + return; + } + + const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); + ussDownloadOptions.generateDirectory = getOption("Generate Directory Structure"); + ussDownloadOptions.chooseEncoding = getOption("Choose Encoding"); + + // Only set directory-specific options when downloading directories + if (isDirectory) { + ussDownloadOptions.overwrite = getOption("Overwrite"); + ussDownloadOptions.includeHidden = getOption("Include Hidden Files"); + } + + if (ussDownloadOptions.chooseEncoding) { + const ussApi = ZoweExplorerApiRegister.getUssApi(node.getProfile()); + let taggedEncoding: string; + + // Only get tagged encoding for files, not directories + if (ussApi.getTag != null && !isDirectory) { + taggedEncoding = await ussApi.getTag(node.fullPath); + } + + ussDownloadOptions.encoding = await SharedUtils.promptForEncoding(node, taggedEncoding !== "untagged" ? taggedEncoding : undefined); + if (!ussDownloadOptions.encoding) { + return; + } + } + + const dialogOptions: vscode.OpenDialogOptions = { + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: vscode.l10n.t("Select Download Location"), + defaultUri: ussDownloadOptions.selectedPath, + }; + + const downloadPath = await Gui.showOpenDialog(dialogOptions); + if (!downloadPath || downloadPath.length === 0) { + return; + } + + const selectedPath = downloadPath[0].fsPath; + ussDownloadOptions.selectedPath = vscode.Uri.file(selectedPath); + await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.USS_DOWNLOAD_OPTIONS, ussDownloadOptions); + + return ussDownloadOptions; + } + + public static async downloadUssFile(node: IZoweUSSTreeNode): Promise { + ZoweLogger.trace("uss.actions.downloadUssFile called."); + + const downloadOptions = await USSActions.getUssDownloadOptions(node); + if (!downloadOptions) { + Gui.showMessage(vscode.l10n.t("Operation cancelled")); + return; + } + + await Gui.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t("Downloading USS file..."), + cancellable: true, + }, + async () => { + const options: zosfiles.IDownloadSingleOptions = { + file: downloadOptions.generateDirectory + ? path.join(downloadOptions.selectedPath.fsPath, node.fullPath) + : path.join(downloadOptions.selectedPath.fsPath, path.basename(node.fullPath)), + binary: downloadOptions.encoding?.kind === "binary", + encoding: downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : undefined, + }; + + try { + await zosfiles.Download.ussFile(node.getSession(), node.fullPath, options); + Gui.showMessage(vscode.l10n.t("File downloaded successfully")); + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile: node.getProfile() }); + } + } + ); + } + + public static async downloadUssDirectory(node: IZoweUSSTreeNode): Promise { + ZoweLogger.trace("uss.actions.downloadUssDirectory called."); + + const downloadOptions = await USSActions.getUssDownloadOptions(node, true); + if (!downloadOptions) { + Gui.showMessage(vscode.l10n.t("Operation cancelled")); + return; + } + + const children = await node.getChildren(); + if (!children || children.length === 0) { + Gui.infoMessage(vscode.l10n.t("The selected directory is empty.")); + return; + } + + if (children.length > Constants.MIN_WARN_DOWNLOAD_FILES) { + const proceed = await Gui.showMessage( + vscode.l10n.t( + "This directory has {0} members. Downloading a large number of files may take a long time. Do you want to continue?", + children.length + ), + { severity: MessageSeverity.WARN, items: [vscode.l10n.t("Yes"), vscode.l10n.t("No")], vsCodeOpts: { modal: true } } + ); + if (proceed !== vscode.l10n.t("Yes")) { + return; + } + } + + await Gui.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t("Downloading USS directory..."), + cancellable: true, + }, + async (progress, token) => { + let realPercentComplete = 0; + const realTotalEntries = children.length; + const task: imperative.ITaskWithStatus = { + set percentComplete(value: number) { + realPercentComplete = value; + // eslint-disable-next-line no-magic-numbers + Gui.reportProgress(progress, realTotalEntries, Math.floor((value * realTotalEntries) / 100), ""); + }, + get percentComplete(): number { + return realPercentComplete; + }, + statusMessage: "", + stageName: 0, // TaskStage.IN_PROGRESS + }; + + const options: zosfiles.IDownloadOptions = { + directory: downloadOptions.generateDirectory + ? path.join(downloadOptions.selectedPath.fsPath, node.fullPath) + : downloadOptions.selectedPath.fsPath, + overwrite: downloadOptions.overwrite, + binary: downloadOptions.encoding?.kind === "binary", + encoding: downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : undefined, + includeHidden: downloadOptions.includeHidden, + maxConcurrentRequests: node.getProfile()?.profile?.maxConcurrentRequests || 1, + task, + responseTimeout: node.getProfile()?.profile?.responseTimeout, + }; + + try { + if (token.isCancellationRequested) { + Gui.showMessage(vscode.l10n.t("Download cancelled")); + return; + } + + await zosfiles.Download.ussDir(node.getSession(), node.fullPath, options); + + Gui.showMessage(vscode.l10n.t("Directory downloaded successfully")); + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile: node.getProfile() }); + } + } + ); + } + public static editAttributes(context: vscode.ExtensionContext, fileProvider: Types.IZoweUSSTreeType, node: IZoweUSSTreeNode): USSAttributeView { return new USSAttributeView(context, fileProvider, node); } diff --git a/packages/zowe-explorer/src/trees/uss/USSInit.ts b/packages/zowe-explorer/src/trees/uss/USSInit.ts index adbd2a4471..cf8b65f032 100644 --- a/packages/zowe-explorer/src/trees/uss/USSInit.ts +++ b/packages/zowe-explorer/src/trees/uss/USSInit.ts @@ -128,6 +128,16 @@ export class USSInit { await USSActions.uploadDialogWithEncoding(node, ussFileProvider); }) ); + context.subscriptions.push( + vscode.commands.registerCommand("zowe.uss.downloadFile", async (node: IZoweUSSTreeNode): Promise => { + await USSActions.downloadUssFile(node); + }) + ); + context.subscriptions.push( + vscode.commands.registerCommand("zowe.uss.downloadDirectory", async (node: IZoweUSSTreeNode): Promise => { + await USSActions.downloadUssDirectory(node); + }) + ); context.subscriptions.push(vscode.commands.registerCommand("zowe.uss.copyPath", (node: IZoweUSSTreeNode): void => USSActions.copyPath(node))); context.subscriptions.push( vscode.commands.registerCommand("zowe.uss.editFile", async (node: IZoweUSSTreeNode): Promise => { From 5f81fc30302b3072d2b37f8365cb39dfa8e6fbe8 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 30 Aug 2025 20:05:47 +0100 Subject: [PATCH 12/29] fix: count dir children recursively Signed-off-by: JWaters02 --- .../zowe-explorer/src/trees/uss/USSActions.ts | 23 +++++++------- .../zowe-explorer/src/trees/uss/USSUtils.ts | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/zowe-explorer/src/trees/uss/USSActions.ts b/packages/zowe-explorer/src/trees/uss/USSActions.ts index d9f7550399..12810b6fa5 100644 --- a/packages/zowe-explorer/src/trees/uss/USSActions.ts +++ b/packages/zowe-explorer/src/trees/uss/USSActions.ts @@ -28,6 +28,7 @@ import { SharedUtils } from "../shared/SharedUtils"; import { AuthUtils } from "../../utils/AuthUtils"; import { Definitions } from "../../configuration/Definitions"; import { ZoweLocalStorage } from "../../tools/ZoweLocalStorage"; +import { USSUtils } from "./USSUtils"; export class USSActions { /** @@ -324,6 +325,10 @@ export class USSActions { ussDownloadOptions.chooseEncoding ??= false; ussDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); + if (USSUtils.zosEncodingToString(ussDownloadOptions.encoding) == "text") { + ussDownloadOptions.encoding = undefined; + } + const optionItems: vscode.QuickPickItem[] = [ { label: vscode.l10n.t("Generate Directory Structure"), @@ -339,9 +344,7 @@ export class USSActions { ussDownloadOptions.encoding.kind === "binary" ? "binary" : ussDownloadOptions.encoding.kind === "other" - ? ussDownloadOptions.encoding.codepage || "default" - : ussDownloadOptions.encoding.kind === "text" - ? "text" + ? ussDownloadOptions.encoding.codepage : "default", ], comment: ["Encoding kind or codepage"], @@ -489,17 +492,17 @@ export class USSActions { return; } - const children = await node.getChildren(); - if (!children || children.length === 0) { - Gui.infoMessage(vscode.l10n.t("The selected directory is empty.")); + const totalFileCount = await USSUtils.countAllFilesRecursively(node); + if (totalFileCount === 0) { + Gui.infoMessage(vscode.l10n.t("The selected directory contains no files to download.")); return; } - if (children.length > Constants.MIN_WARN_DOWNLOAD_FILES) { + if (totalFileCount > Constants.MIN_WARN_DOWNLOAD_FILES) { const proceed = await Gui.showMessage( vscode.l10n.t( "This directory has {0} members. Downloading a large number of files may take a long time. Do you want to continue?", - children.length + totalFileCount ), { severity: MessageSeverity.WARN, items: [vscode.l10n.t("Yes"), vscode.l10n.t("No")], vsCodeOpts: { modal: true } } ); @@ -511,12 +514,12 @@ export class USSActions { await Gui.withProgress( { location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t("Downloading USS directory..."), + title: vscode.l10n.t("Downloading USS directory"), cancellable: true, }, async (progress, token) => { let realPercentComplete = 0; - const realTotalEntries = children.length; + const realTotalEntries = totalFileCount; const task: imperative.ITaskWithStatus = { set percentComplete(value: number) { realPercentComplete = value; diff --git a/packages/zowe-explorer/src/trees/uss/USSUtils.ts b/packages/zowe-explorer/src/trees/uss/USSUtils.ts index 7e2c0a84aa..f0c608f8af 100644 --- a/packages/zowe-explorer/src/trees/uss/USSUtils.ts +++ b/packages/zowe-explorer/src/trees/uss/USSUtils.ts @@ -109,4 +109,34 @@ export class USSUtils { return null; } } + + /** + * Recursively counts all files in a directory tree + * @param node The directory node to count files in + * @returns The total number of files (not directories) in the tree + */ + public static async countAllFilesRecursively(node: IZoweUSSTreeNode): Promise { + ZoweLogger.trace("uss.actions.countAllFilesRecursively called."); + let totalCount = 0; + + try { + const children = await node.getChildren(); + if (!children || children.length === 0) { + return 0; + } + + for (const child of children) { + if (SharedContext.isUssDirectory(child)) { + totalCount += await this.countAllFilesRecursively(child); + } else { + totalCount += 1; + } + } + } catch (error) { + ZoweLogger.warn(`Failed to count files in directory ${node.fullPath}: ${String(error)}`); + return 0; + } + + return totalCount; + } } From 9c6c4eda063571b2b94a84e3f4b0c0fd3918066d Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 18 Oct 2025 18:30:09 +0100 Subject: [PATCH 13/29] refactor: fallback to profile encoding Signed-off-by: JWaters02 --- .../zowe-explorer/src/trees/uss/USSActions.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/zowe-explorer/src/trees/uss/USSActions.ts b/packages/zowe-explorer/src/trees/uss/USSActions.ts index 12810b6fa5..8c55660600 100644 --- a/packages/zowe-explorer/src/trees/uss/USSActions.ts +++ b/packages/zowe-explorer/src/trees/uss/USSActions.ts @@ -452,6 +452,8 @@ export class USSActions { public static async downloadUssFile(node: IZoweUSSTreeNode): Promise { ZoweLogger.trace("uss.actions.downloadUssFile called."); + const profile = node.getProfile(); + const downloadOptions = await USSActions.getUssDownloadOptions(node); if (!downloadOptions) { Gui.showMessage(vscode.l10n.t("Operation cancelled")); @@ -470,14 +472,14 @@ export class USSActions { ? path.join(downloadOptions.selectedPath.fsPath, node.fullPath) : path.join(downloadOptions.selectedPath.fsPath, path.basename(node.fullPath)), binary: downloadOptions.encoding?.kind === "binary", - encoding: downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : undefined, + encoding: downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : profile.profile?.encoding, }; try { await zosfiles.Download.ussFile(node.getSession(), node.fullPath, options); Gui.showMessage(vscode.l10n.t("File downloaded successfully")); } catch (e) { - await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile: node.getProfile() }); + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile }); } } ); @@ -486,6 +488,8 @@ export class USSActions { public static async downloadUssDirectory(node: IZoweUSSTreeNode): Promise { ZoweLogger.trace("uss.actions.downloadUssDirectory called."); + const profile = node.getProfile(); + const downloadOptions = await USSActions.getUssDownloadOptions(node, true); if (!downloadOptions) { Gui.showMessage(vscode.l10n.t("Operation cancelled")); @@ -539,11 +543,11 @@ export class USSActions { : downloadOptions.selectedPath.fsPath, overwrite: downloadOptions.overwrite, binary: downloadOptions.encoding?.kind === "binary", - encoding: downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : undefined, + encoding: downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : profile.profile?.encoding, includeHidden: downloadOptions.includeHidden, - maxConcurrentRequests: node.getProfile()?.profile?.maxConcurrentRequests || 1, + maxConcurrentRequests: profile?.profile?.maxConcurrentRequests || 1, task, - responseTimeout: node.getProfile()?.profile?.responseTimeout, + responseTimeout: profile?.profile?.responseTimeout, }; try { @@ -556,7 +560,7 @@ export class USSActions { Gui.showMessage(vscode.l10n.t("Directory downloaded successfully")); } catch (e) { - await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile: node.getProfile() }); + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile }); } } ); From ed7983154f7db8622578889677d2852c8b50db51 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 18 Oct 2025 19:18:57 +0100 Subject: [PATCH 14/29] fix: implement downloadAllDatasets into FtpMvsApi Signed-off-by: JWaters02 --- .../zowe-explorer-ftp-extension/src/ZoweExplorerFtpMvsApi.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/zowe-explorer-ftp-extension/src/ZoweExplorerFtpMvsApi.ts b/packages/zowe-explorer-ftp-extension/src/ZoweExplorerFtpMvsApi.ts index 8cb2c53f05..0748e9c186 100644 --- a/packages/zowe-explorer-ftp-extension/src/ZoweExplorerFtpMvsApi.ts +++ b/packages/zowe-explorer-ftp-extension/src/ZoweExplorerFtpMvsApi.ts @@ -371,6 +371,10 @@ export class FtpMvsApi extends AbstractFtpApi implements MainframeInteraction.IM } } + public downloadAllMembers(_dataSetName: string, _options?: zosfiles.IDownloadOptions): Promise { + throw new ZoweFtpExtensionError("Download all members is not supported in ftp extension."); + } + public hMigrateDataSet(_dataSetName: string): Promise { throw new ZoweFtpExtensionError("Migrate dataset is not supported in ftp extension."); } From a0031773e730ade4823437f18a5d59106629ef38 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 18 Oct 2025 19:22:44 +0100 Subject: [PATCH 15/29] tests: update command count Signed-off-by: JWaters02 --- packages/zowe-explorer/src/configuration/Constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/zowe-explorer/src/configuration/Constants.ts b/packages/zowe-explorer/src/configuration/Constants.ts index 00c71165ee..28d99b33d5 100644 --- a/packages/zowe-explorer/src/configuration/Constants.ts +++ b/packages/zowe-explorer/src/configuration/Constants.ts @@ -16,7 +16,7 @@ import { imperative, PersistenceSchemaEnum } from "@zowe/zowe-explorer-api"; import type { Profiles } from "./Profiles"; export class Constants { - public static readonly COMMAND_COUNT = 126; + public static readonly COMMAND_COUNT = 127; public static readonly MAX_SEARCH_HISTORY = 5; public static readonly MAX_FILE_HISTORY = 10; public static readonly MAX_DISPLAYED_DELETE_NAMES = 10; From 11beac652e9565ba275aad7006db43830aa55ef4 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 18 Oct 2025 19:44:11 +0100 Subject: [PATCH 16/29] tests: all tests passing, ready for Download test cases Signed-off-by: JWaters02 --- .../__tests__/__unit__/extension.unit.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts index f91b3c5fa2..5568e67162 100644 --- a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts @@ -165,10 +165,6 @@ async function createGlobalMocks() { "zowe.ds.uploadDialog", "zowe.ds.uploadDialogWithEncoding", "zowe.ds.deleteMember", - "zowe.ds.downloadDataset", - "zowe.ds.downloadAllDatasets", - "zowe.ds.downloadMember", - "zowe.ds.downloadAllMembers", "zowe.ds.editDataSet", "zowe.ds.editMember", "zowe.ds.submitJcl", @@ -189,6 +185,9 @@ async function createGlobalMocks() { "zowe.ds.filteredDataSetsSearchFor", "zowe.ds.tableView", "zowe.ds.listDataSets", + "zowe.ds.downloadAllMembers", + "zowe.ds.downloadMember", + "zowe.ds.downloadDataSet", "zowe.uss.addSession", "zowe.uss.refreshAll", "zowe.uss.refresh", @@ -205,6 +204,8 @@ async function createGlobalMocks() { "zowe.uss.uploadDialog", "zowe.uss.uploadDialogBinary", "zowe.uss.uploadDialogWithEncoding", + "zowe.uss.downloadFile", + "zowe.uss.downloadDirectory", "zowe.uss.copyPath", "zowe.uss.editFile", "zowe.uss.editAttributes", From 5c15080eebcfb6e449ffee539bc6384e23b8a815 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sat, 18 Oct 2025 23:13:34 +0100 Subject: [PATCH 17/29] tests: data set download functions tests Signed-off-by: JWaters02 --- .../__mocks__/@zowe/zos-files-for-zowe-sdk.ts | 5 + .../__tests__/__mocks__/vscode.ts | 4 +- .../trees/dataset/DatasetActions.unit.test.ts | 840 +++++++++++++++++- .../src/trees/dataset/DatasetActions.ts | 11 +- 4 files changed, 847 insertions(+), 13 deletions(-) diff --git a/packages/zowe-explorer/__tests__/__mocks__/@zowe/zos-files-for-zowe-sdk.ts b/packages/zowe-explorer/__tests__/__mocks__/@zowe/zos-files-for-zowe-sdk.ts index a98db6f3b9..e20d3f3104 100644 --- a/packages/zowe-explorer/__tests__/__mocks__/@zowe/zos-files-for-zowe-sdk.ts +++ b/packages/zowe-explorer/__tests__/__mocks__/@zowe/zos-files-for-zowe-sdk.ts @@ -186,3 +186,8 @@ export class IZosFilesResponse { */ public apiResponse?: any; } + +export class ZosFilesUtils { + public static getDirsFromDataSet = jest.fn().mockReturnValue("test/dataset/path"); + public static DEFAULT_FILE_EXTENSION = "txt"; +} diff --git a/packages/zowe-explorer/__tests__/__mocks__/vscode.ts b/packages/zowe-explorer/__tests__/__mocks__/vscode.ts index 58aa638e95..f15de20975 100644 --- a/packages/zowe-explorer/__tests__/__mocks__/vscode.ts +++ b/packages/zowe-explorer/__tests__/__mocks__/vscode.ts @@ -1651,7 +1651,9 @@ export interface TextDocument { export class Uri { private static _regexp = /^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/; public static file(path: string): Uri { - return Uri.parse(path); + const uri = Uri.parse(path); + uri.fsPath = path; + return uri; } public static parse(value: string, _strict?: boolean): Uri { const match = Uri._regexp.exec(value); diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts index b9f4b955b4..e69a49cdac 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts @@ -11,7 +11,18 @@ import * as vscode from "vscode"; import * as zosfiles from "@zowe/zos-files-for-zowe-sdk"; -import { Gui, imperative, Validation, ProfilesCache, ZoweExplorerApiType, Sorting, PdsEntry, DirEntry } from "@zowe/zowe-explorer-api"; +import * as path from "path"; +import { + Gui, + imperative, + Validation, + ProfilesCache, + ZoweExplorerApiType, + Sorting, + PdsEntry, + DirEntry, + MainframeInteraction, +} from "@zowe/zowe-explorer-api"; import { DatasetFSProvider } from "../../../../src/trees/dataset/DatasetFSProvider"; import { bindMvsApi, createMvsApi } from "../../../__mocks__/mockCreators/api"; import { @@ -49,6 +60,9 @@ import { SharedTreeProviders } from "../../../../src/trees/shared/SharedTreeProv import { DataSetAttributesProvider } from "../../../../../zowe-explorer-api/lib/dataset/DatasetAttributesProvider"; import { DatasetTree } from "../../../../src/trees/dataset/DatasetTree"; import { ZoweExplorerApiRegister } from "../../../../src/extending/ZoweExplorerApiRegister"; +import { ZoweLocalStorage } from "../../../../src/tools/ZoweLocalStorage"; +import { LocalFileManagement } from "../../../../src/management/LocalFileManagement"; +import { SharedContext } from "../../../../src/trees/shared/SharedContext"; // Missing the definition of path module, because I need the original logic for tests jest.mock("fs"); @@ -56,8 +70,8 @@ jest.mock("vscode"); jest.mock("../../../../src/tools/ZoweLogger"); jest.mock("../../../../src/tools/ZoweLocalStorage"); -let mockClipboardData = null; -let clipboard; +let mockClipboardData: null = null; +let clipboard: { readText: any; writeText: any }; function createGlobalMocks() { clipboard = { @@ -445,7 +459,28 @@ describe("Dataset Actions Unit Tests - Function refreshPS", () => { }); describe("Dataset Actions Unit Tests - Function deleteDatasetPrompt", () => { - function createBlockMocks(globalMocks) { + function createBlockMocks(globalMocks: { + imperativeProfile: any; + profileInstance?: null; + session: any; + treeView: any; + datasetSessionNode: any; + datasetSessionFavNode: any; + testFavoritesNode: any; + testDatasetTree?: null; + getContentsSpy?: null; + fspDelete?: jest.SpyInstance, [uri: vscode.Uri, options?: { recursive?: boolean; useTrash?: boolean } | undefined], any>; + statusBarMsgSpy?: null; + mvsApi?: null; + mockShowWarningMessage?: jest.Mock; + showInputBox?: jest.Mock; + getConfiguration?: jest.SpyInstance< + vscode.WorkspaceConfiguration, + [section?: string | undefined, scope?: vscode.ConfigurationScope | null | undefined], + any + >; + fetchAllMock?: jest.Mock; + }) { const testDatasetTree = createDatasetTree(globalMocks.datasetSessionNode, globalMocks.treeView, globalMocks.testFavoritesNode); const testDatasetNode = new ZoweDatasetNode({ label: "HLQ.TEST.DS", @@ -3089,7 +3124,16 @@ describe("Dataset Actions Unit Tests - Function zoom", () => { } function setupMocksForZoom( - blockMocks, + blockMocks: { + session?: imperative.Session; + imperativeProfile?: imperative.IProfileLoaded; + datasetSessionNode?: ZoweDatasetNode; + profileInstance?: any; + testDatasetTree?: any; + mvsApi?: MainframeInteraction.IMvs; + document: any; + editor: any; + }, selectionText: string, datasetName: string, memberName: string, @@ -3533,4 +3577,788 @@ describe("Dataset Actions Unit Tests - upload with encoding", () => { }); }); -describe("DatasetActions - downloading functions", () => {}); +describe("DatasetActions - downloading functions", () => { + const defaultDownloadOptions = { + overwrite: true, + generateDirectory: false, + preserveCase: false, + binary: false, + record: false, + selectedPath: vscode.Uri.file("/test/download/path"), + }; + + const defaultTestProfile = { + name: "testProfile", + profile: { + host: "test.host.com", + port: 443, + user: "testuser", + password: "testpass", + rejectUnauthorized: false, + responseTimeout: 30000, + maxConcurrentRequests: 5, + }, + type: "zosmf", + message: "", + failNotFound: false, + }; + + function createDownloadTestMocks() { + const session = createISession(); + const imperativeProfile = createIProfile(); + const treeView = createTreeView(); + const datasetSessionNode = createDatasetSessionNode(session, imperativeProfile); + const testDatasetTree = createDatasetTree(datasetSessionNode, treeView); + + const mvsApi = { + downloadAllMembers: jest.fn().mockResolvedValue({ + success: true, + commandResponse: "", + apiResponse: { etag: "123" }, + }), + getContents: jest.fn().mockResolvedValue({ + success: true, + commandResponse: "", + apiResponse: { etag: "123" }, + }), + } as any; + + bindMvsApi(mvsApi); + + const profileInstance = createInstanceOfProfile(imperativeProfile); + mocked(Profiles.getInstance).mockReturnValue(profileInstance); + profileInstance.validProfile = Validation.ValidationType.VALID; + + return { + session, + imperativeProfile, + treeView, + datasetSessionNode, + testDatasetTree, + mvsApi, + profileInstance, + }; + } + + describe("getDataSetDownloadOptions", () => { + let mockQuickPick: any; + let mockZoweLocalStorage: MockedProperty; + let mockGui: MockedProperty; + let mockShowOpenDialog: MockedProperty; + + beforeEach(() => { + mockQuickPick = { + title: "", + placeholder: "", + ignoreFocusOut: false, + canSelectMany: false, + items: [], + selectedItems: [], + onDidAccept: jest.fn(), + onDidHide: jest.fn(), + show: jest.fn(), + hide: jest.fn(), + dispose: jest.fn(), + }; + + new MockedProperty(Gui, "createQuickPick", undefined, jest.fn().mockReturnValue(mockQuickPick)); + mockShowOpenDialog = new MockedProperty(Gui, "showOpenDialog", undefined, jest.fn()); + mockZoweLocalStorage = new MockedProperty(ZoweLocalStorage, "getValue", undefined, jest.fn()); + mockGui = new MockedProperty(Gui, "showMessage", undefined, jest.fn()); + + new MockedProperty(ZoweLocalStorage, "setValue", undefined, jest.fn()); + new MockedProperty(LocalFileManagement, "getDefaultUri", undefined, jest.fn().mockReturnValue(vscode.Uri.file("/default/path"))); + }); + + it("should return download options with default values when no stored values exist", async () => { + mockZoweLocalStorage.mock.mockReturnValue(undefined); + + // Mock user selecting only "Overwrite" option + mockQuickPick.onDidAccept.mockImplementation((callback: any) => { + mockQuickPick.selectedItems = [{ label: vscode.l10n.t("Overwrite"), picked: true }]; + callback(); + }); + + mockShowOpenDialog.mock.mockResolvedValue([vscode.Uri.file("/user/selected/path")]); + + const result = await DatasetActions["getDataSetDownloadOptions"](); + + expect(result).toEqual({ + overwrite: true, + generateDirectory: false, + preserveCase: false, + binary: false, + record: false, + selectedPath: vscode.Uri.file("/user/selected/path"), + }); + expect(mockQuickPick.show).toHaveBeenCalled(); + expect(mockShowOpenDialog.mock).toHaveBeenCalledWith({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: expect.any(String), + defaultUri: expect.any(Object), + }); + }); + + it("should use stored values as initial selection", async () => { + const storedOptions = { + overwrite: false, + generateDirectory: true, + preserveCase: true, + binary: true, + record: false, + selectedPath: vscode.Uri.file("/stored/path"), + }; + mockZoweLocalStorage.mock.mockReturnValue(storedOptions); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [ + { label: "Generate Directory Structure", picked: true }, + { label: "Preserve Original Letter Case", picked: true }, + { label: "Binary", picked: true }, + ]; + callback(); + }); + + mockShowOpenDialog.mock.mockResolvedValue([vscode.Uri.file("/new/path")]); + + const result = await DatasetActions["getDataSetDownloadOptions"](); + + expect(result.generateDirectory).toBe(true); + expect(result.preserveCase).toBe(true); + expect(result.binary).toBe(true); + expect(result.overwrite).toBe(false); + expect(result.record).toBe(false); + }); + + it("should return undefined when user cancels quick pick selection", async () => { + mockZoweLocalStorage.mock.mockReturnValue(defaultDownloadOptions); + + mockQuickPick.onDidHide.mockImplementation((callback: () => void) => { + callback(); + }); + + const result = await DatasetActions["getDataSetDownloadOptions"](); + + expect(result).toBeUndefined(); + expect(mockGui.mock).toHaveBeenCalledWith("Operation cancelled"); + }); + + it("should return undefined when user cancels folder selection", async () => { + mockZoweLocalStorage.mock.mockReturnValue(defaultDownloadOptions); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = []; + callback(); + }); + + mockShowOpenDialog.mock.mockResolvedValue(undefined); + + const result = await DatasetActions["getDataSetDownloadOptions"](); + + expect(result).toBeUndefined(); + expect(mockGui.mock).toHaveBeenCalledWith("Operation cancelled"); + }); + + it("should handle empty folder selection", async () => { + mockZoweLocalStorage.mock.mockReturnValue(defaultDownloadOptions); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = []; + callback(); + }); + + mockShowOpenDialog.mock.mockResolvedValue([]); + + const result = await DatasetActions["getDataSetDownloadOptions"](); + + expect(result).toBeUndefined(); + expect(mockGui.mock).toHaveBeenCalledWith("Operation cancelled"); + }); + + it("should allow selecting no options (all unchecked)", async () => { + mockZoweLocalStorage.mock.mockReturnValue(defaultDownloadOptions); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = []; + callback(); + }); + + mockShowOpenDialog.mock.mockResolvedValue([vscode.Uri.file("/test/path")]); + + const result = await DatasetActions["getDataSetDownloadOptions"](); + + expect(result).toEqual({ + overwrite: false, + generateDirectory: false, + preserveCase: false, + binary: false, + record: false, + selectedPath: vscode.Uri.file("/test/path"), + }); + }); + }); + + describe("generateDirectoryPath", () => { + it("should return base path when generateDirectory is false", () => { + const result = DatasetActions["generateDirectoryPath"]("TEST.DATASET", vscode.Uri.file("/base/path"), false, false); + + expect(result).toBe("/base/path"); + }); + + it("should generate directory path with preserved case", () => { + const mockGetDirsFromDataSet = new MockedProperty( + zosfiles.ZosFilesUtils, + "getDirsFromDataSet", + undefined, + jest.fn().mockReturnValue("test/dataset/path") + ); + + const result = DatasetActions["generateDirectoryPath"]("TEST.DATASET", vscode.Uri.file("/base/path"), true, true); + + expect(mockGetDirsFromDataSet.mock).toHaveBeenCalledWith("TEST.DATASET"); + expect(result).toBe(path.join("/base/path", "TEST/DATASET/PATH")); + + mockGetDirsFromDataSet[Symbol.dispose](); + }); + + it("should generate directory path without preserving case", () => { + const mockGetDirsFromDataSet = new MockedProperty( + zosfiles.ZosFilesUtils, + "getDirsFromDataSet", + undefined, + jest.fn().mockReturnValue("test/dataset/path") + ); + + const result = DatasetActions["generateDirectoryPath"]("TEST.DATASET", vscode.Uri.file("/base/path"), true, false); + + expect(result).toBe(path.join("/base/path", "test/dataset/path")); + + mockGetDirsFromDataSet[Symbol.dispose](); + }); + }); + + describe("downloadAllMembers", () => { + let testMocks: ReturnType; + let mockGetDataSetDownloadOptions: MockedProperty; + let mockExecuteDownloadWithProgress: MockedProperty; + let mockGetChildren: jest.Mock; + + beforeEach(() => { + testMocks = createDownloadTestMocks(); + mockGetDataSetDownloadOptions = new MockedProperty( + DatasetActions, + "getDataSetDownloadOptions" as any, + undefined, + jest.fn().mockResolvedValue(defaultDownloadOptions) + ); + mockExecuteDownloadWithProgress = new MockedProperty( + DatasetActions, + "executeDownloadWithProgress" as any, + undefined, + jest.fn().mockImplementation(async (_title, downloadFn, _successMessage, _node) => { + await downloadFn(); + }) + ); + + mockGetChildren = jest.fn(); + + new MockedProperty(Gui, "showMessage", undefined, jest.fn()); + new MockedProperty(Gui, "errorMessage", undefined, jest.fn()); + new MockedProperty(ZoweLogger, "trace", undefined, jest.fn()); + new MockedProperty(ZoweLocalStorage, "getValue", undefined, jest.fn()); + }); + + it("should successfully download all members of a PDS", async () => { + const pdsNode = new ZoweDatasetNode({ + label: "TEST.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + const memberNodes = [ + new ZoweDatasetNode({ label: "MEMBER1", collapsibleState: vscode.TreeItemCollapsibleState.None, parentNode: pdsNode }), + new ZoweDatasetNode({ label: "MEMBER2", collapsibleState: vscode.TreeItemCollapsibleState.None, parentNode: pdsNode }), + ]; + + pdsNode.getChildren = mockGetChildren.mockResolvedValue(memberNodes); + pdsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + jest.spyOn(testMocks.mvsApi, "downloadAllMembers").mockResolvedValue({ + success: true, + commandResponse: "", + apiResponse: { etag: "123" }, + }); + + await DatasetActions.downloadAllMembers(pdsNode); + + expect(mockGetDataSetDownloadOptions.mock).toHaveBeenCalled(); + expect(mockExecuteDownloadWithProgress.mock).toHaveBeenCalledWith( + "Downloading all members", + expect.any(Function), + "Dataset downloaded successfully", + pdsNode + ); + }); + + it("should handle invalid profile", async () => { + const pdsNode = new ZoweDatasetNode({ + label: "TEST.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + testMocks.profileInstance.validProfile = Validation.ValidationType.INVALID; + pdsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + await DatasetActions.downloadAllMembers(pdsNode); + + expect(Gui.errorMessage).toHaveBeenCalledWith("Profile is invalid, check connection details."); + expect(mockGetDataSetDownloadOptions.mock).not.toHaveBeenCalled(); + }); + + it("should handle PDS with no members", async () => { + const pdsNode = new ZoweDatasetNode({ + label: "TEST.EMPTY.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + pdsNode.getChildren = mockGetChildren.mockResolvedValue([]); + pdsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + await DatasetActions.downloadAllMembers(pdsNode); + + expect(Gui.showMessage).toHaveBeenCalledWith("The selected data set has no members to download."); + expect(mockGetDataSetDownloadOptions.mock).not.toHaveBeenCalled(); + }); + + it("should handle PDS with null children", async () => { + const pdsNode = new ZoweDatasetNode({ + label: "TEST.NULL.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + pdsNode.getChildren = mockGetChildren.mockResolvedValue(null); + pdsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + await DatasetActions.downloadAllMembers(pdsNode); + + expect(Gui.showMessage).toHaveBeenCalledWith("The selected data set has no members to download."); + }); + + it("should warn user when downloading many members", async () => { + const pdsNode = new ZoweDatasetNode({ + label: "TEST.LARGE.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + // Create more than MIN_WARN_DOWNLOAD_FILES members + const memberNodes = Array.from( + { length: Constants.MIN_WARN_DOWNLOAD_FILES + 10 }, + (_, i) => new ZoweDatasetNode({ label: `MEMBER${i}`, collapsibleState: vscode.TreeItemCollapsibleState.None, parentNode: pdsNode }) + ); + + pdsNode.getChildren = mockGetChildren.mockResolvedValue(memberNodes); + pdsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + const mockShowMessage = new MockedProperty(Gui, "showMessage", undefined, jest.fn().mockResolvedValue("No")); + + await DatasetActions.downloadAllMembers(pdsNode); + + expect(mockShowMessage.mock).toHaveBeenCalledWith( + expect.stringMatching(/large number of files.*continue/i), + expect.objectContaining({ + severity: expect.any(Number), + items: expect.arrayContaining(["Yes", "No"]), + }) + ); + expect(mockGetDataSetDownloadOptions.mock).not.toHaveBeenCalled(); + }); + + it("should proceed when user confirms downloading many members", async () => { + const pdsNode = new ZoweDatasetNode({ + label: "TEST.LARGE.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + const memberNodes = Array.from( + { length: Constants.MIN_WARN_DOWNLOAD_FILES + 1 }, + (_, i) => new ZoweDatasetNode({ label: `MEMBER${i}`, collapsibleState: vscode.TreeItemCollapsibleState.None, parentNode: pdsNode }) + ); + + pdsNode.getChildren = mockGetChildren.mockResolvedValue(memberNodes); + pdsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + const mockShowMessage = new MockedProperty(Gui, "showMessage", undefined, jest.fn().mockResolvedValue("Yes")); + + await DatasetActions.downloadAllMembers(pdsNode); + + expect(mockShowMessage.mock).toHaveBeenCalled(); + expect(mockGetDataSetDownloadOptions.mock).toHaveBeenCalled(); + }); + + it("should return early when download options are cancelled", async () => { + const pdsNode = new ZoweDatasetNode({ + label: "TEST.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + const memberNodes = [ + new ZoweDatasetNode({ label: "MEMBER1", collapsibleState: vscode.TreeItemCollapsibleState.None, parentNode: pdsNode }), + ]; + + pdsNode.getChildren = mockGetChildren.mockResolvedValue(memberNodes); + pdsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + mockGetDataSetDownloadOptions.mock.mockResolvedValue(undefined); + + await DatasetActions.downloadAllMembers(pdsNode); + + expect(mockExecuteDownloadWithProgress.mock).not.toHaveBeenCalled(); + }); + }); + + describe("downloadMember", () => { + let testMocks: ReturnType; + let mockGetDataSetDownloadOptions: MockedProperty; + let mockExecuteDownloadWithProgress: MockedProperty; + + beforeEach(() => { + testMocks = createDownloadTestMocks(); + mockGetDataSetDownloadOptions = new MockedProperty( + DatasetActions, + "getDataSetDownloadOptions" as any, + undefined, + jest.fn().mockResolvedValue(defaultDownloadOptions) + ); + mockExecuteDownloadWithProgress = new MockedProperty( + DatasetActions, + "executeDownloadWithProgress" as any, + undefined, + jest.fn().mockImplementation(async (_title, downloadFn, _successMessage, _node) => { + await downloadFn(); + }) + ); + + new MockedProperty(Gui, "errorMessage", undefined, jest.fn()); + new MockedProperty(ZoweLogger, "trace", undefined, jest.fn()); + new MockedProperty(DatasetUtils, "getExtensionMap", undefined, jest.fn().mockResolvedValue({})); + new MockedProperty(DatasetUtils, "getExtension", undefined, jest.fn().mockReturnValue("txt")); + new MockedProperty(zosfiles.ZosFilesUtils, "getDirsFromDataSet", undefined, jest.fn().mockReturnValue("test/directory")); + }); + + it("should successfully download a PDS member", async () => { + const pdsNode = new ZoweDatasetNode({ + label: "TEST.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + const memberNode = new ZoweDatasetNode({ + label: "MEMBER1", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: pdsNode, + profile: defaultTestProfile, + }); + + memberNode.getParent = jest.fn().mockReturnValue(pdsNode); + memberNode.getLabel = jest.fn().mockReturnValue("MEMBER1"); + memberNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + pdsNode.getLabel = jest.fn().mockReturnValue("TEST.PDS"); + + jest.spyOn(testMocks.mvsApi, "getContents").mockResolvedValue({ + success: true, + commandResponse: "", + apiResponse: { + etag: "123", + }, + }); + + await DatasetActions.downloadMember(memberNode); + + expect(mockGetDataSetDownloadOptions.mock).toHaveBeenCalled(); + expect(mockExecuteDownloadWithProgress.mock).toHaveBeenCalledWith( + "Downloading member", + expect.any(Function), + "Member downloaded successfully", + memberNode + ); + }); + + it("should handle invalid profile", async () => { + const memberNode = new ZoweDatasetNode({ + label: "MEMBER1", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + testMocks.profileInstance.validProfile = Validation.ValidationType.INVALID; + memberNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + await DatasetActions.downloadMember(memberNode); + + expect(Gui.errorMessage).toHaveBeenCalledWith("Profile is invalid, check connection details."); + expect(mockGetDataSetDownloadOptions.mock).not.toHaveBeenCalled(); + }); + + it("should return early when download options are cancelled", async () => { + const memberNode = new ZoweDatasetNode({ + label: "MEMBER1", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + memberNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + mockGetDataSetDownloadOptions.mock.mockResolvedValue(undefined); + + await DatasetActions.downloadMember(memberNode); + + expect(mockExecuteDownloadWithProgress.mock).not.toHaveBeenCalled(); + }); + + it("should handle member with preserve case and generate directory options", async () => { + const optionsWithCase = { + ...defaultDownloadOptions, + preserveCase: true, + generateDirectory: true, + overwrite: true, + }; + mockGetDataSetDownloadOptions.mock.mockResolvedValue(optionsWithCase); + + const pdsNode = new ZoweDatasetNode({ + label: "TEST.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + }); + + const memberNode = new ZoweDatasetNode({ + label: "Member1", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: pdsNode, + profile: defaultTestProfile, + }); + + memberNode.getParent = jest.fn().mockReturnValue(pdsNode); + memberNode.getLabel = jest.fn().mockReturnValue("Member1"); + memberNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + pdsNode.getLabel = jest.fn().mockReturnValue("TEST.PDS"); + + const getContentsSpy = jest.spyOn(testMocks.mvsApi, "getContents").mockResolvedValue({ + success: true, + commandResponse: "", + apiResponse: { etag: "123" }, + }); + + await DatasetActions.downloadMember(memberNode); + + expect(mockExecuteDownloadWithProgress.mock).toHaveBeenCalled(); + const downloadFn = mockExecuteDownloadWithProgress.mock.mock.calls[0][1]; + await downloadFn(); + + expect(getContentsSpy).toHaveBeenCalledWith( + "TEST.PDS(Member1)", + expect.objectContaining({ + file: expect.stringMatching(/Member1\.txt$/), + binary: false, + record: false, + overwrite: true, + responseTimeout: 30000, + }) + ); + }); + }); + + describe("downloadDataSet", () => { + let testMocks: ReturnType; + let mockGetDataSetDownloadOptions: MockedProperty; + let mockExecuteDownloadWithProgress: MockedProperty; + let mockIsPds: MockedProperty; + let mockIsVsam: MockedProperty; + + beforeEach(() => { + testMocks = createDownloadTestMocks(); + mockGetDataSetDownloadOptions = new MockedProperty( + DatasetActions, + "getDataSetDownloadOptions" as any, + undefined, + jest.fn().mockResolvedValue(defaultDownloadOptions) + ); + mockExecuteDownloadWithProgress = new MockedProperty( + DatasetActions, + "executeDownloadWithProgress" as any, + undefined, + jest.fn().mockImplementation(async (_title, downloadFn, _successMessage, _node) => { + await downloadFn(); + }) + ); + + mockIsPds = new MockedProperty(SharedContext, "isPds", undefined, jest.fn().mockReturnValue(false)); + mockIsVsam = new MockedProperty(SharedContext, "isVsam", undefined, jest.fn().mockReturnValue(false)); + + new MockedProperty(Gui, "errorMessage", undefined, jest.fn()); + new MockedProperty(Gui, "showMessage", undefined, jest.fn()); + new MockedProperty(ZoweLogger, "trace", undefined, jest.fn()); + new MockedProperty(DatasetUtils, "getExtension", undefined, jest.fn().mockReturnValue("txt")); + }); + + it("should successfully download a sequential dataset", async () => { + const dsNode = new ZoweDatasetNode({ + label: "TEST.DATASET.SEQ", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + dsNode.getLabel = jest.fn().mockReturnValue("TEST.DATASET.SEQ"); + dsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + const getContentsSpy = jest.spyOn(testMocks.mvsApi, "getContents").mockResolvedValue(undefined); + + await DatasetActions.downloadDataSet(dsNode); + + expect(mockGetDataSetDownloadOptions.mock).toHaveBeenCalled(); + expect(mockExecuteDownloadWithProgress.mock).toHaveBeenCalledWith( + "Downloading data set", + expect.any(Function), + "Data set downloaded successfully", + dsNode + ); + + const downloadFn = mockExecuteDownloadWithProgress.mock.mock.calls[0][1]; + await downloadFn(); + expect(getContentsSpy).toHaveBeenCalledWith( + "TEST.DATASET.SEQ", + expect.objectContaining({ + file: expect.stringMatching(/test\.dataset\.seq\.txt$/), + binary: false, + record: false, + overwrite: false, + responseTimeout: 30000, + }) + ); + }); + + it("should reject PDS datasets", async () => { + const pdsNode = new ZoweDatasetNode({ + label: "TEST.PDS", + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + mockIsPds.mock.mockReturnValue(true); + pdsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + await DatasetActions.downloadDataSet(pdsNode); + + expect(Gui.showMessage).toHaveBeenCalledWith("Cannot download this type of data set."); + expect(mockGetDataSetDownloadOptions.mock).not.toHaveBeenCalled(); + }); + + it("should reject VSAM datasets", async () => { + const vsamNode = new ZoweDatasetNode({ + label: "TEST.VSAM", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + mockIsVsam.mock.mockReturnValue(true); + vsamNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + await DatasetActions.downloadDataSet(vsamNode); + + expect(Gui.showMessage).toHaveBeenCalledWith("Cannot download this type of data set."); + expect(mockGetDataSetDownloadOptions.mock).not.toHaveBeenCalled(); + }); + + it("should handle invalid profile", async () => { + const dsNode = new ZoweDatasetNode({ + label: "TEST.DATASET", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + testMocks.profileInstance.validProfile = Validation.ValidationType.INVALID; + dsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + await DatasetActions.downloadDataSet(dsNode); + + expect(Gui.errorMessage).toHaveBeenCalledWith("Profile is invalid, check connection details."); + expect(mockGetDataSetDownloadOptions.mock).not.toHaveBeenCalled(); + }); + + it("should return early when download options are cancelled", async () => { + const dsNode = new ZoweDatasetNode({ + label: "TEST.DATASET", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + dsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + mockGetDataSetDownloadOptions.mock.mockResolvedValue(undefined); + + await DatasetActions.downloadDataSet(dsNode); + + expect(mockExecuteDownloadWithProgress.mock).not.toHaveBeenCalled(); + }); + + it("should handle generate directory option correctly", async () => { + const optionsWithDirectory = { + ...defaultDownloadOptions, + generateDirectory: true, + preserveCase: true, + overwrite: true, + }; + mockGetDataSetDownloadOptions.mock.mockResolvedValue(optionsWithDirectory); + + const dsNode = new ZoweDatasetNode({ + label: "TEST.MULTI.LEVEL.DATASET", + collapsibleState: vscode.TreeItemCollapsibleState.None, + parentNode: testMocks.datasetSessionNode, + profile: defaultTestProfile, + }); + + dsNode.getLabel = jest.fn().mockReturnValue("TEST.MULTI.LEVEL.DATASET"); + dsNode.getProfile = jest.fn().mockReturnValue(defaultTestProfile); + + const getContentsSpy = jest.spyOn(testMocks.mvsApi, "getContents").mockResolvedValue(undefined); + + await DatasetActions.downloadDataSet(dsNode); + + expect(mockExecuteDownloadWithProgress.mock).toHaveBeenCalled(); + + const downloadFn = mockExecuteDownloadWithProgress.mock.mock.calls[0][1]; + await downloadFn(); + + expect(getContentsSpy).toHaveBeenCalledWith( + "TEST.MULTI.LEVEL.DATASET", + expect.objectContaining({ + file: expect.stringMatching(/DATASET\.txt$/), + binary: false, + record: false, + overwrite: true, + responseTimeout: 30000, + }) + ); + }); + }); +}); diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index 0c917f82d3..ab055f9751 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -625,12 +625,11 @@ export class DatasetActions { dataSetDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); const optionItems: vscode.QuickPickItem[] = [ - // TODO: Re-add overwrite option when enhancement is added to API to support it - // { - // label: vscode.l10n.t("Overwrite"), - // description: vscode.l10n.t("Overwrite existing files"), - // picked: dataSetDownloadOptions.overwrite, - // }, + { + label: vscode.l10n.t("Overwrite"), + description: vscode.l10n.t("Overwrite existing files"), + picked: dataSetDownloadOptions.overwrite, + }, { label: vscode.l10n.t("Generate Directory Structure"), description: vscode.l10n.t("Generates sub-folders based on the data set name"), From efb87ef28b9914019e92420703e7e13037170f99 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 19 Oct 2025 11:59:52 +0100 Subject: [PATCH 18/29] tests: uss download functions tests Signed-off-by: JWaters02 --- .../trees/uss/USSActions.unit.test.ts | 647 +++++++++++++++++- 1 file changed, 646 insertions(+), 1 deletion(-) diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts index 3a45d7025e..0d6092c1f2 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts @@ -26,7 +26,7 @@ import { import { createUssApi, bindUssApi } from "../../../__mocks__/mockCreators/api"; import { Constants } from "../../../../src/configuration/Constants"; import { UssFSProvider } from "../../../../src/trees/uss/UssFSProvider"; -import { Gui, Validation, imperative } from "@zowe/zowe-explorer-api"; +import { Gui, Validation, imperative, ZoweExplorerApiType, MessageSeverity } from "@zowe/zowe-explorer-api"; import { SharedUtils } from "../../../../src/trees/shared/SharedUtils"; import { Profiles } from "../../../../src/configuration/Profiles"; import { ZoweLocalStorage } from "../../../../src/tools/ZoweLocalStorage"; @@ -40,8 +40,10 @@ import { AuthUtils } from "../../../../src/utils/AuthUtils"; import { IZoweTree } from "../../../../../zowe-explorer-api/src/tree/IZoweTree"; import { IZoweUSSTreeNode } from "../../../../../zowe-explorer-api/src/tree"; import { USSAttributeView } from "../../../../src/trees/uss/USSAttributeView"; +import { USSUtils } from "../../../../src/trees/uss/USSUtils"; import { mocked } from "../../../__mocks__/mockUtils"; import { USSTree } from "../../../../src/trees/uss/USSTree"; +import { LocalFileManagement } from "../../../../src/management/LocalFileManagement"; jest.mock("../../../../src/tools/ZoweLogger"); jest.mock("fs"); @@ -62,6 +64,8 @@ function createGlobalMocks() { withProgress: jest.fn(), writeText: jest.fn(), showInformationMessage: jest.fn(), + showMessage: jest.fn(), + infoMessage: jest.fn(), fileList: jest.fn(), setStatusBarMessage: jest.fn().mockReturnValue({ dispose: jest.fn() }), showWarningMessage: jest.fn(), @@ -143,6 +147,9 @@ function createGlobalMocks() { value: globalMocks.isBinaryFileSync, configurable: true, }); + Object.defineProperty(Gui, "showMessage", { value: globalMocks.showMessage, configurable: true }); + Object.defineProperty(Gui, "infoMessage", { value: globalMocks.infoMessage, configurable: true }); + Object.defineProperty(globalMocks.Download, "ussDir", { value: jest.fn(), configurable: true }); Object.defineProperty(vscode.env.clipboard, "writeText", { value: globalMocks.writeText, configurable: true }); Object.defineProperty(vscode, "ProgressLocation", { value: globalMocks.ProgressLocation, configurable: true }); Object.defineProperty(vscode.workspace, "applyEdit", { value: jest.fn(), configurable: true }); @@ -1056,3 +1063,641 @@ describe("USS Action Unit Tests - function copyRelativePath", () => { expect(mocked(vscode.env.clipboard.writeText)).toHaveBeenCalledWith("usstest"); }); }); + +describe("USS Action Unit Tests - downloading functions", () => { + let globalMocks: any; + let mockQuickPick: any; + let mockZoweLocalStorage: any; + let mockShowOpenDialog: any; + + beforeEach(() => { + globalMocks = createGlobalMocks(); + + mockQuickPick = { + title: "", + placeholder: "", + ignoreFocusOut: false, + canSelectMany: false, + items: [], + selectedItems: [], + onDidAccept: jest.fn(), + onDidHide: jest.fn(), + show: jest.fn(), + hide: jest.fn(), + dispose: jest.fn(), + }; + + jest.spyOn(Gui, "createQuickPick").mockReturnValue(mockQuickPick); + mockShowOpenDialog = jest.spyOn(Gui, "showOpenDialog"); + mockZoweLocalStorage = jest.spyOn(ZoweLocalStorage, "getValue"); + jest.spyOn(ZoweLocalStorage, "setValue").mockResolvedValue(); + jest.spyOn(LocalFileManagement, "getDefaultUri").mockReturnValue(vscode.Uri.file("/default/path")); + + jest.spyOn(USSUtils, "zosEncodingToString").mockImplementation((encoding) => { + if (!encoding) return "text"; + switch (encoding.kind) { + case "binary": + return "binary"; + case "other": + return encoding.codepage; + default: + return "text"; + } + }); + + jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(5); + jest.spyOn(SharedUtils, "promptForEncoding").mockResolvedValue({ kind: "other", codepage: "IBM-1047" }); + jest.spyOn(ZoweExplorerApiRegister, "getUssApi").mockReturnValue({ getTag: jest.fn().mockResolvedValue("untagged") } as any); + jest.spyOn(AuthUtils, "errorHandling").mockImplementation(); + + jest.clearAllMocks(); + }); + + const createMockNode = (): IZoweUSSTreeNode => { + const mockNode = createUSSNode(createISession(), createIProfile()) as IZoweUSSTreeNode; + mockNode.fullPath = "/u/test/file.txt"; + return mockNode; + }; + + describe("getUssDownloadOptions", () => { + it("should return default options when no stored values exist for file download", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue(undefined); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [{ label: "Generate Directory Structure", picked: true }]; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/user/selected/path")]); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, false); + + expect(result).toEqual({ + overwrite: false, + generateDirectory: true, + includeHidden: false, + chooseEncoding: false, + selectedPath: vscode.Uri.file("/user/selected/path"), + }); + expect(mockQuickPick.show).toHaveBeenCalled(); + expect(mockShowOpenDialog).toHaveBeenCalledWith({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: "Select Download Location", + defaultUri: expect.any(Object), + }); + }); + + it("should return directory-specific options when downloading directories", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue(undefined); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [ + { label: "Overwrite", picked: true }, + { label: "Include Hidden Files", picked: true }, + { label: "Generate Directory Structure", picked: true }, + ]; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/user/selected/path")]); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, true); + + expect(result.overwrite).toBe(true); + expect(result.includeHidden).toBe(true); + expect(result.generateDirectory).toBe(true); + }); + + it("should use stored values as initial selection", async () => { + const mockNode = createMockNode(); + const storedOptions = { + overwrite: true, + generateDirectory: false, + includeHidden: true, + chooseEncoding: false, + selectedPath: vscode.Uri.file("/stored/path"), + }; + mockZoweLocalStorage.mockReturnValue(storedOptions); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [{ label: "Choose Encoding", picked: true }]; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/new/path")]); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, true); + + expect(result.chooseEncoding).toBe(true); + expect(result.selectedPath.fsPath).toBe("/new/path"); + }); + + it("should return undefined when user cancels quick pick selection", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue({}); + + mockQuickPick.onDidHide.mockImplementation((callback: () => void) => { + callback(); + }); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, false); + + expect(result).toBeUndefined(); + }); + + it("should return undefined when user cancels folder selection", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue({}); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = []; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue(undefined); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, false); + + expect(result).toBeUndefined(); + }); + + it("should return undefined when user cancels encoding selection", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue({}); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [{ label: "Choose Encoding", picked: true }]; + callback(); + }); + + jest.spyOn(SharedUtils, "promptForEncoding").mockResolvedValue(undefined); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, false); + + expect(result).toBeUndefined(); + }); + + it("should handle empty folder selection", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue({}); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = []; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([]); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, false); + + expect(result).toBeUndefined(); + }); + + it("should allow selecting no options (all unchecked)", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue({}); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = []; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/test/path")]); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, false); + + expect(result).toEqual({ + overwrite: false, + generateDirectory: false, + includeHidden: false, + chooseEncoding: false, + selectedPath: vscode.Uri.file("/test/path"), + }); + }); + + it("should get tagged encoding for files when choosing encoding", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue({}); + const mockUssApi = { getTag: jest.fn().mockResolvedValue("utf-8") } as any; + jest.spyOn(ZoweExplorerApiRegister, "getUssApi").mockReturnValue(mockUssApi); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [{ label: "Choose Encoding", picked: true }]; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/test/path")]); + + await (USSActions as any).getUssDownloadOptions(mockNode, false); + + expect(mockUssApi.getTag).toHaveBeenCalledWith("/u/test/file.txt"); + expect(SharedUtils.promptForEncoding).toHaveBeenCalledWith(mockNode, "utf-8"); + }); + + it("should not get tagged encoding for directories when choosing encoding", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue({}); + const mockUssApi = { getTag: jest.fn().mockResolvedValue("utf-8") } as any; + jest.spyOn(ZoweExplorerApiRegister, "getUssApi").mockReturnValue(mockUssApi); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [{ label: "Choose Encoding", picked: true }]; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/test/path")]); + + await (USSActions as any).getUssDownloadOptions(mockNode, true); + + expect(mockUssApi.getTag).not.toHaveBeenCalled(); + expect(SharedUtils.promptForEncoding).toHaveBeenCalledWith(mockNode, undefined); + }); + }); + + describe("downloadUssFile", () => { + it("should download a USS file successfully with default encoding", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + encoding: undefined, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback(); + }); + + await USSActions.downloadUssFile(mockNode); + + expect(ZoweLogger.trace).toHaveBeenCalledWith("uss.actions.downloadUssFile called."); + expect(globalMocks.ussFile).toHaveBeenCalledWith( + mockNode.getSession(), + "/u/test/file.txt", + expect.objectContaining({ + file: expect.stringContaining("file.txt"), + binary: false, + encoding: undefined, + }) + ); + expect(globalMocks.showMessage).toHaveBeenCalledWith("File downloaded successfully"); + }); + + it("should download a USS file with binary encoding", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + encoding: { kind: "binary" }, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback(); + }); + + await USSActions.downloadUssFile(mockNode); + + expect(globalMocks.ussFile).toHaveBeenCalledWith( + mockNode.getSession(), + "/u/test/file.txt", + expect.objectContaining({ + binary: true, + }) + ); + }); + + it("should download a USS file with custom codepage encoding", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + encoding: { kind: "other", codepage: "IBM-1047" }, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback(); + }); + + await USSActions.downloadUssFile(mockNode); + + expect(globalMocks.ussFile).toHaveBeenCalledWith( + mockNode.getSession(), + "/u/test/file.txt", + expect.objectContaining({ + binary: false, + encoding: "IBM-1047", + }) + ); + }); + + it("should download a USS file with directory structure generation", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: true, + encoding: undefined, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback(); + }); + + await USSActions.downloadUssFile(mockNode); + + expect(globalMocks.ussFile).toHaveBeenCalledWith( + mockNode.getSession(), + "/u/test/file.txt", + expect.objectContaining({ + file: expect.stringMatching(/u.test.file\.txt$/), + }) + ); + }); + + it("should show cancellation message when download options are cancelled", async () => { + const mockNode = createMockNode(); + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(undefined); + + await USSActions.downloadUssFile(mockNode); + + expect(globalMocks.showMessage).toHaveBeenCalledWith("Operation cancelled"); + expect(globalMocks.ussFile).not.toHaveBeenCalled(); + }); + + it("should handle download errors properly", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + encoding: undefined, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + + const error = new Error("Download failed"); + globalMocks.ussFile.mockRejectedValue(error); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback(); + }); + + await USSActions.downloadUssFile(mockNode); + + expect(AuthUtils.errorHandling).toHaveBeenCalledWith(error, { + apiType: ZoweExplorerApiType.Uss, + profile: mockNode.getProfile(), + }); + }); + }); + + describe("downloadUssDirectory", () => { + it("should download a USS directory successfully", async () => { + const mockNode = createMockNode(); + mockNode.fullPath = "/u/test/directory"; + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + overwrite: true, + includeHidden: false, + encoding: { kind: "other", codepage: "IBM-1047" }, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(5); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback({ report: jest.fn() }, { isCancellationRequested: false }); + }); + + await USSActions.downloadUssDirectory(mockNode); + + expect(ZoweLogger.trace).toHaveBeenCalledWith("uss.actions.downloadUssDirectory called."); + expect(USSUtils.countAllFilesRecursively).toHaveBeenCalledWith(mockNode); + expect(globalMocks.Download.ussDir).toHaveBeenCalledWith( + mockNode.getSession(), + "/u/test/directory", + expect.objectContaining({ + directory: "/test/download/path", + overwrite: true, + binary: false, + encoding: "IBM-1047", + includeHidden: false, + maxConcurrentRequests: 1, + }) + ); + expect(globalMocks.showMessage).toHaveBeenCalledWith("Directory downloaded successfully"); + }); + + it("should download a USS directory with directory structure generation", async () => { + const mockNode = createMockNode(); + mockNode.fullPath = "/u/test/directory"; + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: true, + overwrite: false, + includeHidden: true, + encoding: { kind: "binary" }, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(3); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback({ report: jest.fn() }, { isCancellationRequested: false }); + }); + + await USSActions.downloadUssDirectory(mockNode); + + expect(globalMocks.Download.ussDir).toHaveBeenCalledWith( + mockNode.getSession(), + "/u/test/directory", + expect.objectContaining({ + directory: expect.stringMatching(/u.test.directory$/), + overwrite: false, + binary: true, + includeHidden: true, + }) + ); + }); + + it("should show info message when directory contains no files", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + overwrite: false, + includeHidden: false, + encoding: { kind: "binary" }, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(0); + + await USSActions.downloadUssDirectory(mockNode); + + expect(globalMocks.infoMessage).toHaveBeenCalledWith("The selected directory contains no files to download."); + expect(globalMocks.Download.ussDir).not.toHaveBeenCalled(); + }); + + it("should show warning and prompt for large directory downloads", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + overwrite: false, + includeHidden: false, + encoding: undefined, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(1000); + + globalMocks.showMessage.mockResolvedValue("Yes"); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback({ report: jest.fn() }, { isCancellationRequested: false }); + }); + + await USSActions.downloadUssDirectory(mockNode); + + expect(globalMocks.showMessage).toHaveBeenCalledWith( + "This directory has {0} members. Downloading a large number of files may take a long time. Do you want to continue?", + expect.objectContaining({ + severity: MessageSeverity.WARN, + items: ["Yes", "No"], + vsCodeOpts: { modal: true }, + }) + ); + expect(globalMocks.Download.ussDir).toHaveBeenCalled(); + }); + + it("should cancel download when user chooses No for large directory", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + overwrite: false, + includeHidden: false, + encoding: undefined, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(1000); + + globalMocks.showMessage.mockResolvedValue("No"); + + await USSActions.downloadUssDirectory(mockNode); + + expect(globalMocks.Download.ussDir).not.toHaveBeenCalled(); + }); + + it("should handle cancellation during download", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + overwrite: false, + includeHidden: false, + encoding: undefined, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(5); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback({ report: jest.fn() }, { isCancellationRequested: true }); + }); + + await USSActions.downloadUssDirectory(mockNode); + + expect(globalMocks.showMessage).toHaveBeenCalledWith("Download cancelled"); + expect(globalMocks.Download.ussDir).not.toHaveBeenCalled(); + }); + + it("should show cancellation message when download options are cancelled", async () => { + const mockNode = createMockNode(); + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(undefined); + + await USSActions.downloadUssDirectory(mockNode); + + expect(globalMocks.showMessage).toHaveBeenCalledWith("Operation cancelled"); + expect(globalMocks.Download.ussDir).not.toHaveBeenCalled(); + }); + + it("should handle download errors properly", async () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + overwrite: false, + includeHidden: false, + encoding: undefined, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(5); + + const error = new Error("Download failed"); + globalMocks.Download.ussDir.mockRejectedValue(error); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback({ report: jest.fn() }, { isCancellationRequested: false }); + }); + + await USSActions.downloadUssDirectory(mockNode); + + expect(AuthUtils.errorHandling).toHaveBeenCalledWith(error, { + apiType: ZoweExplorerApiType.Uss, + profile: mockNode.getProfile(), + }); + }); + + it("should use profile settings for maxConcurrentRequests and responseTimeout", async () => { + const mockNode = createMockNode(); + mockNode.getProfile = jest.fn().mockReturnValue({ + profile: { + encoding: "utf-8", + maxConcurrentRequests: 5, + responseTimeout: 30000, + }, + }); + + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + overwrite: false, + includeHidden: false, + encoding: undefined, + }; + + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); + jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(5); + + globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { + return await callback({ report: jest.fn() }, { isCancellationRequested: false }); + }); + + await USSActions.downloadUssDirectory(mockNode); + + expect(globalMocks.Download.ussDir).toHaveBeenCalledWith( + mockNode.getSession(), + expect.any(String), + expect.objectContaining({ + encoding: "utf-8", + maxConcurrentRequests: 5, + responseTimeout: 30000, + }) + ); + }); + }); +}); From e8bce59850078778ce3ff9af5f226ef95bdd70d0 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 19 Oct 2025 12:52:20 +0100 Subject: [PATCH 19/29] tests: add USSUtils.unit.test.ts with coverage for existing & new funcs Signed-off-by: JWaters02 --- .../__unit__/trees/uss/USSUtils.unit.test.ts | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts new file mode 100644 index 0000000000..f0b7957776 --- /dev/null +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts @@ -0,0 +1,441 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { imperative, IZoweUSSTreeNode, ZosEncoding } from "@zowe/zowe-explorer-api"; +import { USSUtils } from "../../../../src/trees/uss/USSUtils"; +import { ZoweUSSNode } from "../../../../src/trees/uss/ZoweUSSNode"; +import { ZoweExplorerApiRegister } from "../../../../src/extending/ZoweExplorerApiRegister"; +import { ZoweLogger } from "../../../../src/tools/ZoweLogger"; +import { SharedContext } from "../../../../src/trees/shared/SharedContext"; +import { createISession, createIProfile } from "../../../__mocks__/mockCreators/shared"; +import { createUSSNode } from "../../../__mocks__/mockCreators/uss"; +import { MockedProperty } from "../../../__mocks__/mockUtils"; + +jest.mock("../../../../src/tools/ZoweLogger"); +jest.mock("fs"); + +function createGlobalMocks() { + const globalMocks = { + writeText: jest.fn(), + l10nT: jest.fn(), + readdirSync: jest.fn(), + dirname: jest.fn(), + basename: jest.fn(), + getTag: jest.fn(), + isFileTagBinOrAscii: jest.fn(), + getUssApi: jest.fn(), + isUssDirectory: jest.fn(), + getChildren: jest.fn(), + getEncoding: jest.fn(), + setEncoding: jest.fn(), + getProfile: jest.fn(), + }; + + new MockedProperty(vscode.env.clipboard, "writeText", undefined, globalMocks.writeText); + new MockedProperty(vscode.l10n, "t", undefined, globalMocks.l10nT); + new MockedProperty(fs, "readdirSync", undefined, globalMocks.readdirSync); + new MockedProperty(path, "dirname", undefined, globalMocks.dirname); + new MockedProperty(path, "basename", undefined, globalMocks.basename); + new MockedProperty(ZoweExplorerApiRegister, "getUssApi", undefined, globalMocks.getUssApi); + new MockedProperty(SharedContext, "isUssDirectory", undefined, globalMocks.isUssDirectory); + + return globalMocks; +} + +describe("USSUtils Unit Tests - fileExistsCaseSensitiveSync", () => { + let globalMocks: any; + + beforeEach(() => { + globalMocks = createGlobalMocks(); + jest.clearAllMocks(); + }); + + it("should return true when at root directory", () => { + globalMocks.dirname.mockImplementation((filePath: any) => { + if (filePath === "/") return "/"; + return "/parent"; + }); + + const result = USSUtils.fileExistsCaseSensitiveSync("/"); + + expect(result).toBe(true); + expect(ZoweLogger.trace).toHaveBeenCalledWith("uss.utils.fileExistsCaseSensitveSync called."); + }); + + it("should return true when file exists with correct case", () => { + globalMocks.dirname.mockReturnValueOnce("/parent").mockReturnValueOnce("/"); + globalMocks.basename.mockReturnValue("testFile.txt"); + globalMocks.readdirSync.mockReturnValue(["testFile.txt", "otherFile.txt"]); + + const result = USSUtils.fileExistsCaseSensitiveSync("/parent/testFile.txt"); + + expect(result).toBe(true); + expect(globalMocks.readdirSync).toHaveBeenCalledWith("/parent"); + expect(globalMocks.basename).toHaveBeenCalledWith("/parent/testFile.txt"); + }); + + it("should return false when file does not exist", () => { + globalMocks.dirname.mockReturnValueOnce("/parent").mockReturnValueOnce("/"); + globalMocks.basename.mockReturnValue("missingFile.txt"); + globalMocks.readdirSync.mockReturnValue(["testFile.txt", "otherFile.txt"]); + + const result = USSUtils.fileExistsCaseSensitiveSync("/parent/missingFile.txt"); + + expect(result).toBe(false); + expect(globalMocks.readdirSync).toHaveBeenCalledWith("/parent"); + }); + + it("should return false when file exists with different case", () => { + globalMocks.dirname.mockReturnValueOnce("/parent").mockReturnValueOnce("/"); + globalMocks.basename.mockReturnValue("TestFile.txt"); + globalMocks.readdirSync.mockReturnValue(["testFile.txt", "otherFile.txt"]); + + const result = USSUtils.fileExistsCaseSensitiveSync("/parent/TestFile.txt"); + + expect(result).toBe(false); + expect(globalMocks.readdirSync).toHaveBeenCalledWith("/parent"); + }); + + it("should recursively check parent directories", () => { + globalMocks.dirname.mockReturnValueOnce("/parent/subdir").mockReturnValueOnce("/parent").mockReturnValueOnce("/"); + globalMocks.basename.mockReturnValueOnce("testFile.txt").mockReturnValueOnce("subdir"); + globalMocks.readdirSync.mockReturnValueOnce(["testFile.txt"]).mockReturnValueOnce(["subdir"]); + + const result = USSUtils.fileExistsCaseSensitiveSync("/parent/subdir/testFile.txt"); + + expect(result).toBe(true); + expect(globalMocks.readdirSync).toHaveBeenCalledTimes(2); + }); +}); + +describe("USSUtils Unit Tests - autoDetectEncoding", () => { + let globalMocks: any; + + beforeEach(() => { + globalMocks = createGlobalMocks(); + jest.clearAllMocks(); + }); + + it("should return early when node already has binary encoding", async () => { + const mockNode = { + getEncoding: jest.fn().mockResolvedValue({ kind: "binary" }), + setEncoding: jest.fn(), + fullPath: "/test/file.txt", + getProfile: jest.fn(), + } as unknown as IZoweUSSTreeNode; + + await USSUtils.autoDetectEncoding(mockNode); + + expect(mockNode.setEncoding).not.toHaveBeenCalled(); + expect(globalMocks.getUssApi).not.toHaveBeenCalled(); + }); + + it("should return early when node already has defined encoding", async () => { + const mockNode = { + getEncoding: jest.fn().mockResolvedValue({ kind: "other", codepage: "IBM-1047" }), + setEncoding: jest.fn(), + fullPath: "/test/file.txt", + getProfile: jest.fn(), + } as unknown as IZoweUSSTreeNode; + + await USSUtils.autoDetectEncoding(mockNode); + + expect(mockNode.setEncoding).not.toHaveBeenCalled(); + expect(globalMocks.getUssApi).not.toHaveBeenCalled(); + }); + + it("should use getTag when available and set binary encoding for binary tag", async () => { + const mockNode = { + getEncoding: jest.fn().mockResolvedValue(undefined), + setEncoding: jest.fn(), + fullPath: "/test/file.txt", + getProfile: jest.fn().mockReturnValue({ profile: {} }), + } as unknown as IZoweUSSTreeNode; + + const mockApi = { + getTag: jest.fn().mockResolvedValue("binary"), + }; + + globalMocks.getUssApi.mockReturnValue(mockApi); + + await USSUtils.autoDetectEncoding(mockNode); + + expect(mockApi.getTag).toHaveBeenCalledWith("/test/file.txt"); + expect(mockNode.setEncoding).toHaveBeenCalledWith({ kind: "binary" }); + }); + + it("should use getTag when available and set binary encoding for mixed tag", async () => { + const mockNode = { + getEncoding: jest.fn().mockResolvedValue(undefined), + setEncoding: jest.fn(), + fullPath: "/test/file.txt", + getProfile: jest.fn().mockReturnValue({ profile: {} }), + } as unknown as IZoweUSSTreeNode; + + const mockApi = { + getTag: jest.fn().mockResolvedValue("mixed"), + }; + + globalMocks.getUssApi.mockReturnValue(mockApi); + + await USSUtils.autoDetectEncoding(mockNode); + + expect(mockApi.getTag).toHaveBeenCalledWith("/test/file.txt"); + expect(mockNode.setEncoding).toHaveBeenCalledWith({ kind: "binary" }); + }); + + it("should use getTag when available and set codepage encoding for tagged file", async () => { + const mockNode = { + getEncoding: jest.fn().mockResolvedValue(undefined), + setEncoding: jest.fn(), + fullPath: "/test/file.txt", + getProfile: jest.fn().mockReturnValue({ profile: {} }), + } as unknown as IZoweUSSTreeNode; + + const mockApi = { + getTag: jest.fn().mockResolvedValue("utf-8"), + }; + + globalMocks.getUssApi.mockReturnValue(mockApi); + + await USSUtils.autoDetectEncoding(mockNode); + + expect(mockApi.getTag).toHaveBeenCalledWith("/test/file.txt"); + expect(mockNode.setEncoding).toHaveBeenCalledWith({ kind: "other", codepage: "utf-8" }); + }); + + it("should use getTag when available and set undefined encoding for untagged file", async () => { + const mockNode = { + getEncoding: jest.fn().mockResolvedValue(undefined), + setEncoding: jest.fn(), + fullPath: "/test/file.txt", + getProfile: jest.fn().mockReturnValue({ profile: {} }), + } as unknown as IZoweUSSTreeNode; + + const mockApi = { + getTag: jest.fn().mockResolvedValue("untagged"), + }; + + globalMocks.getUssApi.mockReturnValue(mockApi); + + await USSUtils.autoDetectEncoding(mockNode); + + expect(mockApi.getTag).toHaveBeenCalledWith("/test/file.txt"); + expect(mockNode.setEncoding).toHaveBeenCalledWith(undefined); + }); + + it("should use isFileTagBinOrAscii when getTag is not available and file is binary", async () => { + const mockNode = { + getEncoding: jest.fn().mockResolvedValue(undefined), + setEncoding: jest.fn(), + fullPath: "/test/file.txt", + getProfile: jest.fn().mockReturnValue({ profile: {} }), + } as unknown as IZoweUSSTreeNode; + + const mockApi = { + getTag: null, + isFileTagBinOrAscii: jest.fn().mockResolvedValue(true), + }; + + globalMocks.getUssApi.mockReturnValue(mockApi); + + await USSUtils.autoDetectEncoding(mockNode); + + expect(mockApi.isFileTagBinOrAscii).toHaveBeenCalledWith("/test/file.txt"); + expect(mockNode.setEncoding).toHaveBeenCalledWith({ kind: "binary" }); + }); + + it("should use isFileTagBinOrAscii when getTag is not available and file is text", async () => { + const mockNode = { + getEncoding: jest.fn().mockResolvedValue(undefined), + setEncoding: jest.fn(), + fullPath: "/test/file.txt", + getProfile: jest.fn().mockReturnValue({ profile: {} }), + } as unknown as IZoweUSSTreeNode; + + const mockApi = { + getTag: null, + isFileTagBinOrAscii: jest.fn().mockResolvedValue(false), + }; + + globalMocks.getUssApi.mockReturnValue(mockApi); + + await USSUtils.autoDetectEncoding(mockNode); + + expect(mockApi.isFileTagBinOrAscii).toHaveBeenCalledWith("/test/file.txt"); + expect(mockNode.setEncoding).toHaveBeenCalledWith(undefined); + }); + + it("should use provided profile instead of node profile", async () => { + const mockNode = { + getEncoding: jest.fn().mockResolvedValue(undefined), + setEncoding: jest.fn(), + fullPath: "/test/file.txt", + getProfile: jest.fn().mockReturnValue({ profile: {} }), + } as unknown as IZoweUSSTreeNode; + + const providedProfile = { profile: { name: "testProfile" } } as unknown as imperative.IProfileLoaded; + + const mockApi = { + getTag: jest.fn().mockResolvedValue("untagged"), + }; + + globalMocks.getUssApi.mockReturnValue(mockApi); + + await USSUtils.autoDetectEncoding(mockNode, providedProfile); + + expect(globalMocks.getUssApi).toHaveBeenCalledWith(providedProfile); + expect(mockNode.getProfile).not.toHaveBeenCalled(); + }); +}); + +describe("USSUtils Unit Tests - countAllFilesRecursively", () => { + let globalMocks: any; + + beforeEach(() => { + globalMocks = createGlobalMocks(); + jest.clearAllMocks(); + }); + + it("should return 0 when node has no children", async () => { + const mockNode = { + getChildren: jest.fn().mockResolvedValue([]), + fullPath: "/test/emptyDir", + } as unknown as IZoweUSSTreeNode; + + const result = await USSUtils.countAllFilesRecursively(mockNode); + + expect(result).toBe(0); + expect(ZoweLogger.trace).toHaveBeenCalledWith("uss.actions.countAllFilesRecursively called."); + }); + + it("should return 0 when getChildren returns null", async () => { + const mockNode = { + getChildren: jest.fn().mockResolvedValue(null), + fullPath: "/test/emptyDir", + } as unknown as IZoweUSSTreeNode; + + const result = await USSUtils.countAllFilesRecursively(mockNode); + + expect(result).toBe(0); + }); + + it("should count files but not directories", async () => { + const mockFile1 = { fullPath: "/test/file1.txt" } as IZoweUSSTreeNode; + const mockFile2 = { fullPath: "/test/file2.txt" } as IZoweUSSTreeNode; + const mockDir = { fullPath: "/test/subdir" } as IZoweUSSTreeNode; + + const mockNode = { + getChildren: jest.fn().mockResolvedValue([mockFile1, mockDir, mockFile2]), + fullPath: "/test", + } as unknown as IZoweUSSTreeNode; + + globalMocks.isUssDirectory.mockImplementation((node: any) => node.fullPath === "/test/subdir"); + + const result = await USSUtils.countAllFilesRecursively(mockNode); + + expect(result).toBe(2); + expect(globalMocks.isUssDirectory).toHaveBeenCalledTimes(3); + }); + + it("should recursively count files in subdirectories", async () => { + const mockFile1 = { fullPath: "/test/file1.txt" } as IZoweUSSTreeNode; + const mockSubFile1 = { fullPath: "/test/subdir/subfile1.txt" } as IZoweUSSTreeNode; + const mockSubFile2 = { fullPath: "/test/subdir/subfile2.txt" } as IZoweUSSTreeNode; + + const mockSubDir = { + getChildren: jest.fn().mockResolvedValue([mockSubFile1, mockSubFile2]), + fullPath: "/test/subdir", + } as unknown as IZoweUSSTreeNode; + + const mockNode = { + getChildren: jest.fn().mockResolvedValue([mockFile1, mockSubDir]), + fullPath: "/test", + } as unknown as IZoweUSSTreeNode; + + globalMocks.isUssDirectory.mockImplementation((node: any) => node.fullPath === "/test/subdir"); + + const result = await USSUtils.countAllFilesRecursively(mockNode); + + expect(result).toBe(3); + expect(mockSubDir.getChildren).toHaveBeenCalled(); + }); + + it("should handle deeply nested directory structures", async () => { + const mockFile1 = { fullPath: "/test/file1.txt" } as IZoweUSSTreeNode; + const mockDeepFile = { fullPath: "/test/sub1/sub2/deepfile.txt" } as IZoweUSSTreeNode; + + const mockSub2 = { + getChildren: jest.fn().mockResolvedValue([mockDeepFile]), + fullPath: "/test/sub1/sub2", + } as unknown as IZoweUSSTreeNode; + + const mockSub1 = { + getChildren: jest.fn().mockResolvedValue([mockSub2]), + fullPath: "/test/sub1", + } as unknown as IZoweUSSTreeNode; + + const mockNode = { + getChildren: jest.fn().mockResolvedValue([mockFile1, mockSub1]), + fullPath: "/test", + } as unknown as IZoweUSSTreeNode; + + globalMocks.isUssDirectory.mockImplementation((node: any) => node.fullPath === "/test/sub1" || node.fullPath === "/test/sub1/sub2"); + + const result = await USSUtils.countAllFilesRecursively(mockNode); + + expect(result).toBe(2); + }); + + it("should return 0 and log warning when getChildren throws error", async () => { + const mockNode = { + getChildren: jest.fn().mockRejectedValue(new Error("Access denied")), + fullPath: "/test/restrictedDir", + } as unknown as IZoweUSSTreeNode; + + const warnSpy = jest.spyOn(ZoweLogger, "warn"); + + const result = await USSUtils.countAllFilesRecursively(mockNode); + + expect(result).toBe(0); + expect(warnSpy).toHaveBeenCalledWith("Failed to count files in directory /test/restrictedDir: Error: Access denied"); + }); + + it("should handle mixed file and directory structure with errors", async () => { + const mockFile1 = { fullPath: "/test/file1.txt" } as IZoweUSSTreeNode; + const mockFile2 = { fullPath: "/test/file2.txt" } as IZoweUSSTreeNode; + + const mockErrorDir = { + getChildren: jest.fn().mockRejectedValue(new Error("Permission denied")), + fullPath: "/test/errordir", + } as unknown as IZoweUSSTreeNode; + + const mockValidDir = { + getChildren: jest.fn().mockResolvedValue([]), + fullPath: "/test/validdir", + } as unknown as IZoweUSSTreeNode; + + const mockNode = { + getChildren: jest.fn().mockResolvedValue([mockFile1, mockErrorDir, mockValidDir, mockFile2]), + fullPath: "/test", + } as unknown as IZoweUSSTreeNode; + + globalMocks.isUssDirectory.mockImplementation((node: any) => node.fullPath === "/test/errordir" || node.fullPath === "/test/validdir"); + + const result = await USSUtils.countAllFilesRecursively(mockNode); + + expect(result).toBe(2); + expect(ZoweLogger.warn).toHaveBeenCalled(); + }); +}); From e25d90f488cf2616113d301dd5e21c9f410323ad Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 19 Oct 2025 13:21:37 +0100 Subject: [PATCH 20/29] tests: USSInit & DatasetInit coverage Signed-off-by: JWaters02 --- .../__unit__/trees/dataset/DatasetInit.unit.test.ts | 8 ++++++++ .../__tests__/__unit__/trees/uss/USSActions.unit.test.ts | 2 +- .../__tests__/__unit__/trees/uss/USSInit.unit.test.ts | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts index 07fb080663..3cb57b3c23 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts @@ -124,6 +124,14 @@ describe("Test src/dataset/extension", () => { name: "zowe.ds.downloadAllMembers", mock: [{ spy: jest.spyOn(DatasetActions, "downloadAllMembers"), arg: [test.value] }], }, + { + name: "zowe.ds.downloadMember", + mock: [{ spy: jest.spyOn(DatasetActions, "downloadMember"), arg: [test.value] }], + }, + { + name: "zowe.ds.downloadDataSet", + mock: [{ spy: jest.spyOn(DatasetActions, "downloadDataSet"), arg: [test.value] }], + }, { name: "zowe.ds.deleteMember", mock: [{ spy: jest.spyOn(DatasetActions, "deleteDatasetPrompt"), arg: [dsProvider, test.value, undefined] }], diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts index 0d6092c1f2..2405111e35 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts @@ -538,7 +538,7 @@ describe("USS Action Unit Tests - Functions uploadDialog & uploadFile", () => { globalMocks.showOpenDialog.mockReturnValue(undefined); await USSActions.uploadDialog(blockMocks.ussNode, blockMocks.testUSSTree, true); expect(globalMocks.showOpenDialog).toHaveBeenCalled(); - expect(globalMocks.showInformationMessage.mock.calls.map((call) => call[0])).toEqual(["Operation cancelled"]); + expect(globalMocks.showMessage.mock.calls.map((call) => call[0])).toEqual(["Operation cancelled"]); }); it("Tests that uploadDialog() throws an error successfully", async () => { diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSInit.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSInit.unit.test.ts index e5a573088e..4196e68114 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSInit.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSInit.unit.test.ts @@ -105,6 +105,14 @@ describe("Test src/uss/extension", () => { name: "zowe.uss.uploadDialogWithEncoding", mock: [{ spy: jest.spyOn(USSActions, "uploadDialogWithEncoding"), arg: [test.value, ussFileProvider] }], }, + { + name: "zowe.uss.downloadFile", + mock: [{ spy: jest.spyOn(USSActions, "downloadUssFile"), arg: [test.value] }], + }, + { + name: "zowe.uss.downloadDirectory", + mock: [{ spy: jest.spyOn(USSActions, "downloadUssDirectory"), arg: [test.value] }], + }, { name: "zowe.uss.copyPath", mock: [{ spy: jest.spyOn(USSActions, "copyPath"), arg: [test.value] }], From 03fa91eb395c110c145d0fcdbf0eeb8d36df00cd Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 19 Oct 2025 13:24:42 +0100 Subject: [PATCH 21/29] tests: remove unused imports Signed-off-by: JWaters02 --- .../__tests__/__unit__/trees/uss/USSUtils.unit.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts index f0b7957776..7245b624fd 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts @@ -12,14 +12,11 @@ import * as vscode from "vscode"; import * as fs from "fs"; import * as path from "path"; -import { imperative, IZoweUSSTreeNode, ZosEncoding } from "@zowe/zowe-explorer-api"; +import { imperative, IZoweUSSTreeNode } from "@zowe/zowe-explorer-api"; import { USSUtils } from "../../../../src/trees/uss/USSUtils"; -import { ZoweUSSNode } from "../../../../src/trees/uss/ZoweUSSNode"; import { ZoweExplorerApiRegister } from "../../../../src/extending/ZoweExplorerApiRegister"; import { ZoweLogger } from "../../../../src/tools/ZoweLogger"; import { SharedContext } from "../../../../src/trees/shared/SharedContext"; -import { createISession, createIProfile } from "../../../__mocks__/mockCreators/shared"; -import { createUSSNode } from "../../../__mocks__/mockCreators/uss"; import { MockedProperty } from "../../../__mocks__/mockUtils"; jest.mock("../../../../src/tools/ZoweLogger"); From a3de6fdc5b913a29ff071f145d6bed0dff3229b3 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 19 Oct 2025 14:05:45 +0100 Subject: [PATCH 22/29] refactor: add downloadDirectory to IUss interface rather than incorrectly calling to zosfiles api directly Signed-off-by: JWaters02 --- .../src/extend/MainframeInteraction.ts | 15 ++++++ .../src/profiles/ZoweExplorerZosmfApi.ts | 12 +++++ .../src/ZoweExplorerFtpUssApi.ts | 17 +++++++ .../trees/uss/USSActions.unit.test.ts | 47 ++++++++++--------- .../zowe-explorer/src/trees/uss/USSActions.ts | 5 +- 5 files changed, 70 insertions(+), 26 deletions(-) diff --git a/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts b/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts index 79842ba43e..d8ecf3f161 100644 --- a/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts +++ b/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts @@ -119,9 +119,24 @@ export namespace MainframeInteraction { * * @param {string} ussFilePath * @param {zosfiles.IDownloadOptions} options + * @returns {Promise} */ getContents(ussFilePath: string, options: zosfiles.IDownloadSingleOptions): Promise; + /** + * Download a USS directory to the local file system. + * + * @param {string} ussDirectoryPath The path of the USS directory to download + * @param {zosfiles.IDownloadOptions} fileOptions Download options including local directory path + * @param {zosfiles.IUSSListOptions} listOptions Options for listing files in USS + * @returns {Promise} + */ + downloadDirectory( + ussDirectoryPath: string, + fileOptions?: zosfiles.IDownloadOptions, + listOptions?: zosfiles.IUSSListOptions + ): Promise; + /** * Uploads a given buffer as the contents of a file on USS. * @param {Buffer} buffer diff --git a/packages/zowe-explorer-api/src/profiles/ZoweExplorerZosmfApi.ts b/packages/zowe-explorer-api/src/profiles/ZoweExplorerZosmfApi.ts index 6765c853e7..e184205b96 100644 --- a/packages/zowe-explorer-api/src/profiles/ZoweExplorerZosmfApi.ts +++ b/packages/zowe-explorer-api/src/profiles/ZoweExplorerZosmfApi.ts @@ -129,6 +129,18 @@ import { IDataSetCount } from "../dataset/IDataSetCount"; }); } + public downloadDirectory( + ussDirectoryPath: string, + fileOptions?: zosfiles.IDownloadOptions, + listOptions?: zosfiles.IUSSListOptions + ): Promise { + return zosfiles.Download.ussDir(this.getSession(), ussDirectoryPath, { + responseTimeout: this.profile?.profile?.responseTimeout, + ...fileOptions, + ...listOptions, + }); + } + public copy(outputPath: string, options?: Omit): Promise { return zosfiles.Utilities.putUSSPayload(this.getSession(), outputPath, { ...options, request: "copy" }); } diff --git a/packages/zowe-explorer-ftp-extension/src/ZoweExplorerFtpUssApi.ts b/packages/zowe-explorer-ftp-extension/src/ZoweExplorerFtpUssApi.ts index 95c0ecc62b..4ad0c4c3f8 100644 --- a/packages/zowe-explorer-ftp-extension/src/ZoweExplorerFtpUssApi.ts +++ b/packages/zowe-explorer-ftp-extension/src/ZoweExplorerFtpUssApi.ts @@ -101,6 +101,23 @@ export class FtpUssApi extends AbstractFtpApi implements MainframeInteraction.IU } } + /** + * Download a USS directory to the local file system. + * Currently not supported by FTP extension. + * + * @param ussDirectoryPath The path of the USS directory to download + * @param fileOptions Download options including local directory path + * @param listOptions Options for listing files in USS + * @returns A file response with the results of the download operation. + */ + public downloadDirectory( + _ussDirectoryPath: string, + _fileOptions?: zosfiles.IDownloadOptions, + _listOptions?: zosfiles.IUSSListOptions + ): Promise { + throw new ZoweFtpExtensionError("Download directory operation is not supported in FTP extension."); + } + /** * Uploads a USS file from the given buffer. * @param buffer The buffer containing the contents of the USS file diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts index 2405111e35..67faddb572 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts @@ -1107,7 +1107,14 @@ describe("USS Action Unit Tests - downloading functions", () => { jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(5); jest.spyOn(SharedUtils, "promptForEncoding").mockResolvedValue({ kind: "other", codepage: "IBM-1047" }); - jest.spyOn(ZoweExplorerApiRegister, "getUssApi").mockReturnValue({ getTag: jest.fn().mockResolvedValue("untagged") } as any); + + globalMocks.ussApi = { + getTag: jest.fn().mockResolvedValue("untagged"), + getContents: jest.fn().mockResolvedValue({ success: true, commandResponse: "", apiResponse: {} }), + downloadDirectory: jest.fn().mockResolvedValue({ success: true, commandResponse: "", apiResponse: {} }), + }; + jest.spyOn(ZoweExplorerApiRegister, "getUssApi").mockReturnValue(globalMocks.ussApi); + jest.spyOn(AuthUtils, "errorHandling").mockImplementation(); jest.clearAllMocks(); @@ -1336,8 +1343,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssFile(mockNode); expect(ZoweLogger.trace).toHaveBeenCalledWith("uss.actions.downloadUssFile called."); - expect(globalMocks.ussFile).toHaveBeenCalledWith( - mockNode.getSession(), + expect(globalMocks.ussApi.getContents).toHaveBeenCalledWith( "/u/test/file.txt", expect.objectContaining({ file: expect.stringContaining("file.txt"), @@ -1364,8 +1370,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssFile(mockNode); - expect(globalMocks.ussFile).toHaveBeenCalledWith( - mockNode.getSession(), + expect(globalMocks.ussApi.getContents).toHaveBeenCalledWith( "/u/test/file.txt", expect.objectContaining({ binary: true, @@ -1389,8 +1394,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssFile(mockNode); - expect(globalMocks.ussFile).toHaveBeenCalledWith( - mockNode.getSession(), + expect(globalMocks.ussApi.getContents).toHaveBeenCalledWith( "/u/test/file.txt", expect.objectContaining({ binary: false, @@ -1415,8 +1419,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssFile(mockNode); - expect(globalMocks.ussFile).toHaveBeenCalledWith( - mockNode.getSession(), + expect(globalMocks.ussApi.getContents).toHaveBeenCalledWith( "/u/test/file.txt", expect.objectContaining({ file: expect.stringMatching(/u.test.file\.txt$/), @@ -1431,7 +1434,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssFile(mockNode); expect(globalMocks.showMessage).toHaveBeenCalledWith("Operation cancelled"); - expect(globalMocks.ussFile).not.toHaveBeenCalled(); + expect(globalMocks.ussApi.getContents).not.toHaveBeenCalled(); }); it("should handle download errors properly", async () => { @@ -1445,7 +1448,7 @@ describe("USS Action Unit Tests - downloading functions", () => { jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(mockDownloadOptions); const error = new Error("Download failed"); - globalMocks.ussFile.mockRejectedValue(error); + globalMocks.ussApi.getContents.mockRejectedValue(error); globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { return await callback(); @@ -1483,8 +1486,8 @@ describe("USS Action Unit Tests - downloading functions", () => { expect(ZoweLogger.trace).toHaveBeenCalledWith("uss.actions.downloadUssDirectory called."); expect(USSUtils.countAllFilesRecursively).toHaveBeenCalledWith(mockNode); - expect(globalMocks.Download.ussDir).toHaveBeenCalledWith( - mockNode.getSession(), + expect(ZoweExplorerApiRegister.getUssApi).toHaveBeenCalledWith(mockNode.getProfile()); + expect(globalMocks.ussApi.downloadDirectory).toHaveBeenCalledWith( "/u/test/directory", expect.objectContaining({ directory: "/test/download/path", @@ -1518,8 +1521,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssDirectory(mockNode); - expect(globalMocks.Download.ussDir).toHaveBeenCalledWith( - mockNode.getSession(), + expect(globalMocks.ussApi.downloadDirectory).toHaveBeenCalledWith( "/u/test/directory", expect.objectContaining({ directory: expect.stringMatching(/u.test.directory$/), @@ -1546,7 +1548,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssDirectory(mockNode); expect(globalMocks.infoMessage).toHaveBeenCalledWith("The selected directory contains no files to download."); - expect(globalMocks.Download.ussDir).not.toHaveBeenCalled(); + expect(globalMocks.ussApi.downloadDirectory).not.toHaveBeenCalled(); }); it("should show warning and prompt for large directory downloads", async () => { @@ -1578,7 +1580,7 @@ describe("USS Action Unit Tests - downloading functions", () => { vsCodeOpts: { modal: true }, }) ); - expect(globalMocks.Download.ussDir).toHaveBeenCalled(); + expect(globalMocks.ussApi.downloadDirectory).toHaveBeenCalled(); }); it("should cancel download when user chooses No for large directory", async () => { @@ -1598,7 +1600,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssDirectory(mockNode); - expect(globalMocks.Download.ussDir).not.toHaveBeenCalled(); + expect(globalMocks.ussApi.downloadDirectory).not.toHaveBeenCalled(); }); it("should handle cancellation during download", async () => { @@ -1621,7 +1623,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssDirectory(mockNode); expect(globalMocks.showMessage).toHaveBeenCalledWith("Download cancelled"); - expect(globalMocks.Download.ussDir).not.toHaveBeenCalled(); + expect(globalMocks.ussApi.downloadDirectory).not.toHaveBeenCalled(); }); it("should show cancellation message when download options are cancelled", async () => { @@ -1631,7 +1633,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssDirectory(mockNode); expect(globalMocks.showMessage).toHaveBeenCalledWith("Operation cancelled"); - expect(globalMocks.Download.ussDir).not.toHaveBeenCalled(); + expect(globalMocks.ussApi.downloadDirectory).not.toHaveBeenCalled(); }); it("should handle download errors properly", async () => { @@ -1648,7 +1650,7 @@ describe("USS Action Unit Tests - downloading functions", () => { jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(5); const error = new Error("Download failed"); - globalMocks.Download.ussDir.mockRejectedValue(error); + globalMocks.ussApi.downloadDirectory.mockRejectedValue(error); globalMocks.withProgress.mockImplementation(async (options: any, callback: any) => { return await callback({ report: jest.fn() }, { isCancellationRequested: false }); @@ -1689,8 +1691,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssDirectory(mockNode); - expect(globalMocks.Download.ussDir).toHaveBeenCalledWith( - mockNode.getSession(), + expect(globalMocks.ussApi.downloadDirectory).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ encoding: "utf-8", diff --git a/packages/zowe-explorer/src/trees/uss/USSActions.ts b/packages/zowe-explorer/src/trees/uss/USSActions.ts index 8c55660600..b746b29f30 100644 --- a/packages/zowe-explorer/src/trees/uss/USSActions.ts +++ b/packages/zowe-explorer/src/trees/uss/USSActions.ts @@ -476,7 +476,7 @@ export class USSActions { }; try { - await zosfiles.Download.ussFile(node.getSession(), node.fullPath, options); + await ZoweExplorerApiRegister.getUssApi(profile).getContents(node.fullPath, options); Gui.showMessage(vscode.l10n.t("File downloaded successfully")); } catch (e) { await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile }); @@ -556,8 +556,7 @@ export class USSActions { return; } - await zosfiles.Download.ussDir(node.getSession(), node.fullPath, options); - + await ZoweExplorerApiRegister.getUssApi(profile).downloadDirectory(node.fullPath, options); Gui.showMessage(vscode.l10n.t("Directory downloaded successfully")); } catch (e) { await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile }); From cab987b886d32502625aa8f4ba4b5aa529644025 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 19 Oct 2025 14:06:25 +0100 Subject: [PATCH 23/29] tests: add mvs/uss download api impl to ftp ext tests Signed-off-by: JWaters02 --- .../profiles/ZoweExplorerZosmfApi.unit.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/zowe-explorer-api/__tests__/__unit__/profiles/ZoweExplorerZosmfApi.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/profiles/ZoweExplorerZosmfApi.unit.test.ts index e8c7810170..9f5c46fa02 100644 --- a/packages/zowe-explorer-api/__tests__/__unit__/profiles/ZoweExplorerZosmfApi.unit.test.ts +++ b/packages/zowe-explorer-api/__tests__/__unit__/profiles/ZoweExplorerZosmfApi.unit.test.ts @@ -457,6 +457,11 @@ describe("ZosmfUssApi", () => { spy: jest.spyOn(zosfiles.Download, "ussFile"), args: ["ussPath", fakeProperties], }, + { + name: "downloadDirectory", + spy: jest.spyOn(zosfiles.Download, "ussDir"), + args: ["localPath", fakeProperties], + }, { name: "putContent", spy: jest.spyOn(zosfiles.Upload, "fileToUssFile"), @@ -513,6 +518,11 @@ describe("ZosmfMvsApi", () => { spy: jest.spyOn(zosfiles.Download, "dataSet"), args: ["dsname", fakeProperties], }, + { + name: "downloadAllMembers", + spy: jest.spyOn(zosfiles.Download, "allMembers"), + args: ["dsname", fakeProperties], + }, { name: "putContents", spy: jest.spyOn(zosfiles.Upload, "pathToDataSet"), From 5da1f2a604c6356428ef5d71452e382a91079d8b Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 19 Oct 2025 16:12:54 +0100 Subject: [PATCH 24/29] wip: add list opts support to uss dir download Signed-off-by: JWaters02 --- .../src/configuration/Definitions.ts | 14 +- .../src/trees/shared/SharedUtils.ts | 40 ++- .../zowe-explorer/src/trees/uss/USSActions.ts | 243 ++++++++++++++++-- .../zowe-explorer/src/trees/uss/USSUtils.ts | 8 +- .../src/trees/uss/UssFSProvider.ts | 19 +- 5 files changed, 290 insertions(+), 34 deletions(-) diff --git a/packages/zowe-explorer/src/configuration/Definitions.ts b/packages/zowe-explorer/src/configuration/Definitions.ts index 931f5622f7..2e39da5310 100644 --- a/packages/zowe-explorer/src/configuration/Definitions.ts +++ b/packages/zowe-explorer/src/configuration/Definitions.ts @@ -52,8 +52,20 @@ export namespace Definitions { generateDirectory?: boolean; includeHidden?: boolean; chooseEncoding?: boolean; - encoding?: ZosEncoding; + encoding?: ZosEncoding | null; selectedPath?: vscode.Uri; + dirFilterOptions?: UssDirFilterOptions; + }; + export type UssDirFilterOptions = { + group?: number | string; + user?: number | string; + mtime?: number | string; + size?: number | string; + perm?: string; + type?: string; + depth?: number; + filesys?: boolean; + symlinks?: boolean; }; export type FavoriteData = { profileName: string; diff --git a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts index ef10d21e62..cab7eb560e 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts @@ -206,9 +206,10 @@ export class SharedUtils { * * @param {imperative.IProfileLoaded} profile - The profile loaded * @param {string} taggedEncoding - The tagged encoding + * @param {boolean} isDirectory - Whether the target is a directory * @returns {vscode.QuickPickItem[]} The encoding options for the prompt */ - private static buildEncodingOptions(profile: imperative.IProfileLoaded, taggedEncoding?: string): vscode.QuickPickItem[] { + private static buildEncodingOptions(profile: imperative.IProfileLoaded, taggedEncoding?: string, isDirectory?: boolean): vscode.QuickPickItem[] { const ebcdicItem: vscode.QuickPickItem = { label: vscode.l10n.t("EBCDIC"), description: vscode.l10n.t("z/OS default codepage"), @@ -221,7 +222,16 @@ export class SharedUtils { label: vscode.l10n.t("Other"), description: vscode.l10n.t("Specify another codepage"), }; - const items: vscode.QuickPickItem[] = [ebcdicItem, binaryItem, otherItem, Constants.SEPARATORS.RECENT]; + const items: vscode.QuickPickItem[] = [ebcdicItem, binaryItem, otherItem]; + + if (isDirectory) { + items.splice(0, 0, { + label: vscode.l10n.t("Auto-detect from file tags"), + description: vscode.l10n.t("Let the API infer encoding from individual USS file tags"), + }); + } + + items.push(Constants.SEPARATORS.RECENT); if (profile.profile?.encoding != null) { items.splice(0, 0, { @@ -258,9 +268,10 @@ export class SharedUtils { * * @param {string} response - The response from the user * @param {string} contextLabel - The context label of the node - * @returns {Promise} The {@link ZosEncoding} object or `undefined` if the user dismisses the prompt + * @returns {Promise} The {@link ZosEncoding} object, `null` for auto-detect, + * or `undefined` if the user dismisses the prompt */ - private static async processEncodingResponse(response: string | undefined, contextLabel: string): Promise { + private static async processEncodingResponse(response: string | undefined, contextLabel: string): Promise { if (!response) { return undefined; } @@ -280,7 +291,7 @@ export class SharedUtils { case binaryLabel: encoding = { kind: "binary" }; break; - case otherLabel: + case otherLabel: { const customResponse = await Gui.showInputBox({ title: vscode.l10n.t({ message: "Choose encoding for {0}", @@ -298,9 +309,16 @@ export class SharedUtils { return undefined; } break; - default: - encoding = response === "binary" ? { kind: "binary" } : { kind: "other", codepage: response }; + } + default: { + const autoDetectLabel = vscode.l10n.t("Auto-detect from file tags"); + if (response === autoDetectLabel) { + return null; + } else { + encoding = response === "binary" ? { kind: "binary" } : { kind: "other", codepage: response }; + } break; + } } return encoding; } @@ -355,12 +373,14 @@ export class SharedUtils { public static async promptForEncoding( node: IZoweDatasetTreeNode | IZoweUSSTreeNode | IZoweJobTreeNode, taggedEncoding?: string - ): Promise { + ): Promise { const profile = node.getProfile(); - const items = SharedUtils.buildEncodingOptions(profile, taggedEncoding); + const isDirectory = SharedUtils.isZoweUSSTreeNode(node) && SharedContext.isUssDirectory(node); + const items = SharedUtils.buildEncodingOptions(profile, taggedEncoding, isDirectory); let zosEncoding = await node.getEncoding(); - if (zosEncoding === undefined && SharedUtils.isZoweUSSTreeNode(node)) { + if (zosEncoding === undefined && SharedUtils.isZoweUSSTreeNode(node) && !isDirectory) { + // Only fetch encoding for USS files, not directories zosEncoding = await UssFSProvider.instance.fetchEncodingForUri(node.resourceUri); } let currentEncoding = zosEncoding ? USSUtils.zosEncodingToString(zosEncoding) : await SharedUtils.getCachedEncoding(node); diff --git a/packages/zowe-explorer/src/trees/uss/USSActions.ts b/packages/zowe-explorer/src/trees/uss/USSActions.ts index b746b29f30..acefb67fd3 100644 --- a/packages/zowe-explorer/src/trees/uss/USSActions.ts +++ b/packages/zowe-explorer/src/trees/uss/USSActions.ts @@ -315,6 +315,190 @@ export class USSActions { } } + private static async getUssDirFilterOptions(currentOptions?: Definitions.UssDirFilterOptions): Promise { + ZoweLogger.trace("uss.actions.configureFilterOptions called."); + + const filterOptions: Definitions.UssDirFilterOptions = currentOptions ?? {}; + const quickPickItems: (vscode.QuickPickItem & { key: keyof Definitions.UssDirFilterOptions; inputType: string })[] = [ + { + label: vscode.l10n.t("Group"), + description: filterOptions.group + ? vscode.l10n.t("Filter by group owner or GID (current: {0})", filterOptions.group.toString()) + : vscode.l10n.t("Filter by group owner or GID"), + key: "group", + inputType: "string", + picked: filterOptions.group != null, + }, + { + label: vscode.l10n.t("User"), + description: filterOptions.user + ? vscode.l10n.t("Filter by user name or UID (current: {0})", filterOptions.user.toString()) + : vscode.l10n.t("Filter by user name or UID"), + key: "user", + inputType: "string", + picked: filterOptions.user != null, + }, + { + label: vscode.l10n.t("Modification Time"), + description: filterOptions.mtime + ? vscode.l10n.t("Filter by modification time in days (current: {0})", filterOptions.mtime.toString()) + : vscode.l10n.t("Filter by modification time in days (e.g., +7, -1, 30)"), + key: "mtime", + inputType: "string", + picked: filterOptions.mtime != null, + }, + { + label: vscode.l10n.t("Size"), + description: filterOptions.size + ? vscode.l10n.t("Filter by file size (current: {0})", filterOptions.size.toString()) + : vscode.l10n.t("Filter by file size (e.g., +1M, -500K, 100G)"), + key: "size", + inputType: "string", + picked: filterOptions.size != null, + }, + { + label: vscode.l10n.t("Permissions"), + description: filterOptions.perm + ? vscode.l10n.t("Filter by permission octal mask (current: {0})", filterOptions.perm) + : vscode.l10n.t("Filter by permission octal mask (e.g., 755, -644)"), + key: "perm", + inputType: "string", + picked: filterOptions.perm != null, + }, + { + label: vscode.l10n.t("File Type"), + description: filterOptions.type + ? vscode.l10n.t("Filter by file type (current: {0})", filterOptions.type) + : vscode.l10n.t("Filter by file type (c=character, d=directory, f=file, l=symlink, p=pipe, s=socket)"), + key: "type", + inputType: "string", + picked: filterOptions.type != null, + }, + { + label: vscode.l10n.t("Depth"), + description: + filterOptions.depth != null + ? vscode.l10n.t("Directory depth to search (current: {0})", filterOptions.depth.toString()) + : vscode.l10n.t("Directory depth to search (number of levels)"), + key: "depth", + inputType: "number", + picked: filterOptions.depth != null, + }, + { + label: vscode.l10n.t("Search All Filesystems"), + description: vscode.l10n.t("Search all filesystems under the path (not just same filesystem)"), + key: "filesys", + inputType: "boolean", + picked: filterOptions.filesys === true, + }, + { + label: vscode.l10n.t("Return Symlinks"), + description: vscode.l10n.t("Return symbolic links instead of following them"), + key: "symlinks", + inputType: "boolean", + picked: filterOptions.symlinks === true, + }, + ]; + + const filterQuickPick = Gui.createQuickPick(); + filterQuickPick.title = vscode.l10n.t("Configure Filter Options"); + filterQuickPick.placeholder = vscode.l10n.t("Select filters to configure"); + filterQuickPick.ignoreFocusOut = true; + filterQuickPick.canSelectMany = true; + filterQuickPick.items = quickPickItems; + filterQuickPick.selectedItems = quickPickItems.filter((item) => item.picked); + + const selectedFilters: typeof quickPickItems = await new Promise((resolve) => { + let wasAccepted = false; + + filterQuickPick.onDidAccept(() => { + wasAccepted = true; + resolve(Array.from(filterQuickPick.selectedItems) as typeof quickPickItems); + filterQuickPick.hide(); + }); + + filterQuickPick.onDidHide(() => { + if (!wasAccepted) { + resolve(null); + } + }); + + filterQuickPick.show(); + }); + filterQuickPick.dispose(); + + if (selectedFilters === null) { + return null; + } + + const newFilterOptions: Definitions.UssDirFilterOptions = {}; + for (const filter of selectedFilters) { + if (filter.inputType === "boolean") { + (newFilterOptions as any)[filter.key] = true; + } else { + const currentValue = filterOptions[filter.key]; + let prompt: string; + let placeholder: string; + + switch (filter.key) { + case "group": + prompt = vscode.l10n.t("Enter group owner or GID"); + placeholder = vscode.l10n.t("e.g., admin or 100"); + break; + case "user": + prompt = vscode.l10n.t("Enter user name or UID"); + placeholder = vscode.l10n.t("e.g., john or 1001"); + break; + case "mtime": + prompt = vscode.l10n.t("Enter modification time filter"); + placeholder = vscode.l10n.t("e.g., +7 (older than 7 days), -1 (newer than 1 day), 30 (exactly 30 days)"); + break; + case "size": + prompt = vscode.l10n.t("Enter size filter"); + placeholder = vscode.l10n.t("e.g., +1M (larger than 1MB), -500K (smaller than 500KB), 100G"); + break; + case "perm": + prompt = vscode.l10n.t("Enter permission octal mask"); + placeholder = vscode.l10n.t("e.g., 755, -644 (not 644)"); + break; + case "type": + prompt = vscode.l10n.t("Enter file type"); + placeholder = vscode.l10n.t("c, d, f, l, p, or s"); + break; + case "depth": + prompt = vscode.l10n.t("Enter directory depth"); + placeholder = vscode.l10n.t("e.g., 2 (search 2 levels deep)"); + break; + } + + const inputValue = await Gui.showInputBox({ + prompt, + placeHolder: placeholder, + value: currentValue?.toString() || "", + validateInput: (value) => { + if (!value.trim()) { + return vscode.l10n.t("Value cannot be empty"); + } + if (filter.inputType === "number" && isNaN(parseInt(value))) { + return vscode.l10n.t("Must be a valid number"); + } + return null; + }, + }); + + if (inputValue != null && inputValue.trim()) { + if (filter.inputType === "number") { + (newFilterOptions as any)[filter.key] = parseInt(inputValue); + } else { + (newFilterOptions as any)[filter.key] = inputValue.trim(); + } + } + } + } + + return newFilterOptions; + } + private static async getUssDownloadOptions(node: IZoweUSSTreeNode, isDirectory: boolean = false): Promise { const ussDownloadOptions: Definitions.UssDownloadOptions = ZoweLocalStorage.getValue(Definitions.LocalStorageKey.USS_DOWNLOAD_OPTIONS) ?? {}; @@ -324,12 +508,20 @@ export class USSActions { ussDownloadOptions.includeHidden ??= false; ussDownloadOptions.chooseEncoding ??= false; ussDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); + ussDownloadOptions.dirFilterOptions ??= {}; if (USSUtils.zosEncodingToString(ussDownloadOptions.encoding) == "text") { ussDownloadOptions.encoding = undefined; } const optionItems: vscode.QuickPickItem[] = [ + { + label: vscode.l10n.t("Overwrite"), + description: isDirectory + ? vscode.l10n.t("Overwrite existing files when downloading directories") + : vscode.l10n.t("Overwrite existing file"), + picked: ussDownloadOptions.overwrite, + }, { label: vscode.l10n.t("Generate Directory Structure"), description: vscode.l10n.t("Generates sub-folders based on the USS path"), @@ -339,7 +531,9 @@ export class USSActions { label: vscode.l10n.t("Choose Encoding"), description: ussDownloadOptions.encoding ? vscode.l10n.t({ - message: "Select specific encoding for files (current: {0})", + message: isDirectory + ? "Select default encoding for directory files (current: {0})" + : "Select specific encoding for file (current: {0})", args: [ ussDownloadOptions.encoding.kind === "binary" ? "binary" @@ -349,25 +543,28 @@ export class USSActions { ], comment: ["Encoding kind or codepage"], }) - : vscode.l10n.t("Select specific encoding for files"), + : isDirectory + ? vscode.l10n.t("Select default encoding for directory files or auto-detect from file tags") + : vscode.l10n.t("Select specific encoding for file"), picked: ussDownloadOptions.chooseEncoding, }, ]; // Add directory-specific options only when downloading directories if (isDirectory) { - optionItems.splice( - 0, - 0, - { - label: vscode.l10n.t("Overwrite"), - description: vscode.l10n.t("Overwrite existing files when downloading directories"), - picked: ussDownloadOptions.overwrite, - }, + optionItems.push( { label: vscode.l10n.t("Include Hidden Files"), description: vscode.l10n.t("Include hidden files (those starting with a dot) when downloading directories"), picked: ussDownloadOptions.includeHidden, + }, + { + label: vscode.l10n.t("Set Filter Options"), + description: + ussDownloadOptions.dirFilterOptions && Object.keys(ussDownloadOptions.dirFilterOptions).length > 0 + ? vscode.l10n.t("Configure file filtering options (currently configured)") + : vscode.l10n.t("Configure file filtering options"), + picked: false, } ); } @@ -405,13 +602,22 @@ export class USSActions { } const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); + ussDownloadOptions.overwrite = getOption("Overwrite"); ussDownloadOptions.generateDirectory = getOption("Generate Directory Structure"); ussDownloadOptions.chooseEncoding = getOption("Choose Encoding"); // Only set directory-specific options when downloading directories if (isDirectory) { - ussDownloadOptions.overwrite = getOption("Overwrite"); ussDownloadOptions.includeHidden = getOption("Include Hidden Files"); + + // Handle filter options configuration + if (getOption("Set Filter Options")) { + const filterOptions = await USSActions.getUssDirFilterOptions(ussDownloadOptions.dirFilterOptions); + if (filterOptions === null) { + return; + } + ussDownloadOptions.dirFilterOptions = filterOptions; + } } if (ussDownloadOptions.chooseEncoding) { @@ -424,9 +630,10 @@ export class USSActions { } ussDownloadOptions.encoding = await SharedUtils.promptForEncoding(node, taggedEncoding !== "untagged" ? taggedEncoding : undefined); - if (!ussDownloadOptions.encoding) { + if (ussDownloadOptions.encoding === undefined) { return; } + // Note: ussDownloadOptions.encoding can be null for auto-detect mode (directories only) } const dialogOptions: vscode.OpenDialogOptions = { @@ -473,6 +680,7 @@ export class USSActions { : path.join(downloadOptions.selectedPath.fsPath, path.basename(node.fullPath)), binary: downloadOptions.encoding?.kind === "binary", encoding: downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : profile.profile?.encoding, + overwrite: downloadOptions.overwrite, }; try { @@ -496,7 +704,7 @@ export class USSActions { return; } - const totalFileCount = await USSUtils.countAllFilesRecursively(node); + const totalFileCount = await USSUtils.countAllFilesRecursively(node, downloadOptions.dirFilterOptions?.depth); if (totalFileCount === 0) { Gui.infoMessage(vscode.l10n.t("The selected directory contains no files to download.")); return; @@ -542,14 +750,19 @@ export class USSActions { ? path.join(downloadOptions.selectedPath.fsPath, node.fullPath) : downloadOptions.selectedPath.fsPath, overwrite: downloadOptions.overwrite, - binary: downloadOptions.encoding?.kind === "binary", - encoding: downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : profile.profile?.encoding, includeHidden: downloadOptions.includeHidden, maxConcurrentRequests: profile?.profile?.maxConcurrentRequests || 1, task, responseTimeout: profile?.profile?.responseTimeout, + ...downloadOptions.dirFilterOptions, }; + // Only set encoding/binary if user chose a specific encoding (not auto-detect) + if (downloadOptions.encoding !== null) { + options.binary = downloadOptions.encoding?.kind === "binary"; + options.encoding = downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : profile.profile?.encoding; + } + try { if (token.isCancellationRequested) { Gui.showMessage(vscode.l10n.t("Download cancelled")); diff --git a/packages/zowe-explorer/src/trees/uss/USSUtils.ts b/packages/zowe-explorer/src/trees/uss/USSUtils.ts index f0c608f8af..0a13c4eb01 100644 --- a/packages/zowe-explorer/src/trees/uss/USSUtils.ts +++ b/packages/zowe-explorer/src/trees/uss/USSUtils.ts @@ -113,9 +113,11 @@ export class USSUtils { /** * Recursively counts all files in a directory tree * @param node The directory node to count files in + * @param maxDepth Optional maximum depth to search (1 = only immediate children) + * @param currentDepth Current depth in the recursion (for internal use) * @returns The total number of files (not directories) in the tree */ - public static async countAllFilesRecursively(node: IZoweUSSTreeNode): Promise { + public static async countAllFilesRecursively(node: IZoweUSSTreeNode, maxDepth?: number, currentDepth: number = 1): Promise { ZoweLogger.trace("uss.actions.countAllFilesRecursively called."); let totalCount = 0; @@ -127,7 +129,9 @@ export class USSUtils { for (const child of children) { if (SharedContext.isUssDirectory(child)) { - totalCount += await this.countAllFilesRecursively(child); + if (maxDepth === undefined || currentDepth < maxDepth) { + totalCount += await this.countAllFilesRecursively(child, maxDepth, currentDepth + 1); + } } else { totalCount += 1; } diff --git a/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts b/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts index c6b1567e3b..fddce04939 100644 --- a/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts +++ b/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts @@ -433,10 +433,17 @@ export class UssFSProvider extends BaseProvider implements vscode.FileSystemProv } public async fetchEncodingForUri(uri: vscode.Uri): Promise { - const file = this._lookupAsFile(uri) as UssFile; - await this.autoDetectEncoding(file); + try { + const file = this._lookupAsFile(uri) as UssFile; + await this.autoDetectEncoding(file); - return file.encoding; + return file.encoding; + } catch (error) { + if (error instanceof vscode.FileSystemError && error.code === "FileIsADirectory") { + throw new Error(vscode.l10n.t("Cannot fetch encoding for directories. Encoding options are only available for files.")); + } + throw error; + } } /** @@ -681,13 +688,13 @@ export class UssFSProvider extends BaseProvider implements vscode.FileSystemProv Gui.errorMessage(err.message); return; } - } catch (err) { - if (err.name === "Error" && Number(err.errorCode) === imperative.RestConstants.HTTP_STATUS_404) { + } catch (listError) { + if (listError.name === "Error" && Number(listError.errorCode) === imperative.RestConstants.HTTP_STATUS_404) { const parent = this.lookupParentDirectory(newUri); parent.entries.delete(path.posix.basename(newUri.path)); await ZoweExplorerApiRegister.getUssApi(profile).rename(entry.metadata.path, newPath); } else { - throw err; + throw listError; } } } else { From 9bf9a9d192ec90a6e2930fc17d3801fc4e74cd15 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Sun, 19 Oct 2025 23:56:07 +0100 Subject: [PATCH 25/29] refactor: uss dir download options Signed-off-by: JWaters02 --- .../src/configuration/Definitions.ts | 12 +- .../src/trees/shared/SharedUtils.ts | 80 ++++- .../zowe-explorer/src/trees/uss/USSActions.ts | 299 ++++++++++-------- .../zowe-explorer/src/trees/uss/USSUtils.ts | 11 + 4 files changed, 260 insertions(+), 142 deletions(-) diff --git a/packages/zowe-explorer/src/configuration/Definitions.ts b/packages/zowe-explorer/src/configuration/Definitions.ts index 2e39da5310..75ed799e52 100644 --- a/packages/zowe-explorer/src/configuration/Definitions.ts +++ b/packages/zowe-explorer/src/configuration/Definitions.ts @@ -50,12 +50,19 @@ export namespace Definitions { export type UssDownloadOptions = { overwrite?: boolean; generateDirectory?: boolean; - includeHidden?: boolean; chooseEncoding?: boolean; encoding?: ZosEncoding | null; selectedPath?: vscode.Uri; + dirOptions?: UssDirOptions; dirFilterOptions?: UssDirFilterOptions; }; + export type UssDirOptions = { + filesys?: boolean; + symlinks?: boolean; + includeHidden?: boolean; + chooseFilterOptions?: boolean; + directoryEncoding?: "auto-detect" | ZosEncoding; + }; export type UssDirFilterOptions = { group?: number | string; user?: number | string; @@ -64,8 +71,6 @@ export namespace Definitions { perm?: string; type?: string; depth?: number; - filesys?: boolean; - symlinks?: boolean; }; export type FavoriteData = { profileName: string; @@ -187,5 +192,6 @@ export namespace Definitions { DISPLAY_RELEASE_NOTES_VERSION = "zowe.displayReleaseNotes", DS_DOWNLOAD_OPTIONS = "zowe.dsDownloadOptions", USS_DOWNLOAD_OPTIONS = "zowe.ussDownloadOptions", + USS_DIRECTORY_ENCODING = "zowe.ussDirectoryEncoding", } } diff --git a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts index cab7eb560e..f16deb70d2 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts @@ -163,6 +163,30 @@ export class SharedUtils { return cachedEncoding?.kind === "other" ? cachedEncoding.codepage : cachedEncoding?.kind; } + /** + * Gets the cached directory encoding preference for a profile. + * @param profileName The profile name to get encoding for + * @returns The cached directory encoding preference or undefined + */ + public static getCachedDirectoryEncoding(profileName: string): "auto-detect" | ZosEncoding | undefined { + const encodingMap = ZoweLocalStorage.getValue>( + Definitions.LocalStorageKey.USS_DIRECTORY_ENCODING + ); + return encodingMap?.[profileName]; + } + + /** + * Saves the directory encoding preference for a profile. + * @param profileName The profile name to save encoding for + * @param encoding The encoding preference to save + */ + public static setCachedDirectoryEncoding(profileName: string, encoding: "auto-detect" | ZosEncoding): void { + const encodingMap = + ZoweLocalStorage.getValue>(Definitions.LocalStorageKey.USS_DIRECTORY_ENCODING) ?? {}; + encodingMap[profileName] = encoding; + ZoweLocalStorage.setValue(Definitions.LocalStorageKey.USS_DIRECTORY_ENCODING, encodingMap); + } + public static parseFavorites(lines: string[]): Definitions.FavoriteData[] { const invalidFavoriteWarning = (line: string): void => ZoweLogger.warn( @@ -375,12 +399,10 @@ export class SharedUtils { taggedEncoding?: string ): Promise { const profile = node.getProfile(); - const isDirectory = SharedUtils.isZoweUSSTreeNode(node) && SharedContext.isUssDirectory(node); - const items = SharedUtils.buildEncodingOptions(profile, taggedEncoding, isDirectory); + const items = SharedUtils.buildEncodingOptions(profile, taggedEncoding, false); let zosEncoding = await node.getEncoding(); - if (zosEncoding === undefined && SharedUtils.isZoweUSSTreeNode(node) && !isDirectory) { - // Only fetch encoding for USS files, not directories + if (zosEncoding === undefined && SharedUtils.isZoweUSSTreeNode(node)) { zosEncoding = await UssFSProvider.instance.fetchEncodingForUri(node.resourceUri); } let currentEncoding = zosEncoding ? USSUtils.zosEncodingToString(zosEncoding) : await SharedUtils.getCachedEncoding(node); @@ -410,6 +432,56 @@ export class SharedUtils { return SharedUtils.processEncodingResponse(response, node.label as string); } + /** + * Prompts user for encoding selection for USS directory downloads. + * + * @param {imperative.IProfileLoaded} profile - The profile loaded + * @param {string} contextLabel - The context label of the directory (e.g. USS directory path) + * @param {"auto-detect" | ZosEncoding} currentDirectoryEncoding - Current directory encoding preference + * @returns {Promise<"auto-detect" | ZosEncoding | undefined>} "auto-detect" string, ZosEncoding object, or undefined if dismissed + */ + public static async promptForDirectoryEncoding( + profile: imperative.IProfileLoaded, + contextLabel: string, + currentDirectoryEncoding?: "auto-detect" | ZosEncoding + ): Promise<"auto-detect" | ZosEncoding | undefined> { + const items = SharedUtils.buildEncodingOptions(profile, undefined, true); + + let currentEncoding: string | undefined; + if (currentDirectoryEncoding === "auto-detect") { + currentEncoding = vscode.l10n.t("Auto-detect from file tags"); + } else if (currentDirectoryEncoding) { + currentEncoding = + currentDirectoryEncoding.kind === "binary" + ? vscode.l10n.t("Binary") + : currentDirectoryEncoding.kind === "text" + ? vscode.l10n.t("EBCDIC") + : `${currentDirectoryEncoding.kind.toUpperCase()}-${currentDirectoryEncoding.codepage}`; + } else { + currentEncoding = vscode.l10n.t("Auto-detect from file tags"); // Default for directories + } + + const response = await SharedUtils.promptForEncodingSelection( + items, + vscode.l10n.t({ + message: "Choose encoding for files in {0}", + args: [contextLabel], + comment: ["Directory path"], + }), + vscode.l10n.t({ + message: "Current encoding is {0}", + args: [currentEncoding], + comment: ["Encoding name"], + }) + ); + + const result = await SharedUtils.processEncodingResponse(response, contextLabel); + if (result === null) { + return "auto-detect"; + } + return result; + } + public static getSessionLabel(node: IZoweTreeNode): string { return (SharedContext.isSession(node) ? node : node.getSessionNode()).label as string; } diff --git a/packages/zowe-explorer/src/trees/uss/USSActions.ts b/packages/zowe-explorer/src/trees/uss/USSActions.ts index acefb67fd3..d5c083a7c0 100644 --- a/packages/zowe-explorer/src/trees/uss/USSActions.ts +++ b/packages/zowe-explorer/src/trees/uss/USSActions.ts @@ -384,20 +384,6 @@ export class USSActions { inputType: "number", picked: filterOptions.depth != null, }, - { - label: vscode.l10n.t("Search All Filesystems"), - description: vscode.l10n.t("Search all filesystems under the path (not just same filesystem)"), - key: "filesys", - inputType: "boolean", - picked: filterOptions.filesys === true, - }, - { - label: vscode.l10n.t("Return Symlinks"), - description: vscode.l10n.t("Return symbolic links instead of following them"), - key: "symlinks", - inputType: "boolean", - picked: filterOptions.symlinks === true, - }, ]; const filterQuickPick = Gui.createQuickPick(); @@ -433,65 +419,61 @@ export class USSActions { const newFilterOptions: Definitions.UssDirFilterOptions = {}; for (const filter of selectedFilters) { - if (filter.inputType === "boolean") { - (newFilterOptions as any)[filter.key] = true; - } else { - const currentValue = filterOptions[filter.key]; - let prompt: string; - let placeholder: string; - - switch (filter.key) { - case "group": - prompt = vscode.l10n.t("Enter group owner or GID"); - placeholder = vscode.l10n.t("e.g., admin or 100"); - break; - case "user": - prompt = vscode.l10n.t("Enter user name or UID"); - placeholder = vscode.l10n.t("e.g., john or 1001"); - break; - case "mtime": - prompt = vscode.l10n.t("Enter modification time filter"); - placeholder = vscode.l10n.t("e.g., +7 (older than 7 days), -1 (newer than 1 day), 30 (exactly 30 days)"); - break; - case "size": - prompt = vscode.l10n.t("Enter size filter"); - placeholder = vscode.l10n.t("e.g., +1M (larger than 1MB), -500K (smaller than 500KB), 100G"); - break; - case "perm": - prompt = vscode.l10n.t("Enter permission octal mask"); - placeholder = vscode.l10n.t("e.g., 755, -644 (not 644)"); - break; - case "type": - prompt = vscode.l10n.t("Enter file type"); - placeholder = vscode.l10n.t("c, d, f, l, p, or s"); - break; - case "depth": - prompt = vscode.l10n.t("Enter directory depth"); - placeholder = vscode.l10n.t("e.g., 2 (search 2 levels deep)"); - break; - } - - const inputValue = await Gui.showInputBox({ - prompt, - placeHolder: placeholder, - value: currentValue?.toString() || "", - validateInput: (value) => { - if (!value.trim()) { - return vscode.l10n.t("Value cannot be empty"); - } - if (filter.inputType === "number" && isNaN(parseInt(value))) { - return vscode.l10n.t("Must be a valid number"); - } - return null; - }, - }); + const currentValue = filterOptions[filter.key]; + let prompt: string; + let placeholder: string; + + switch (filter.key) { + case "group": + prompt = vscode.l10n.t("Enter group owner or GID"); + placeholder = vscode.l10n.t("e.g., admin or 100"); + break; + case "user": + prompt = vscode.l10n.t("Enter user name or UID"); + placeholder = vscode.l10n.t("e.g., john or 1001"); + break; + case "mtime": + prompt = vscode.l10n.t("Enter modification time filter"); + placeholder = vscode.l10n.t("e.g., +7 (older than 7 days), -1 (newer than 1 day), 30 (exactly 30 days)"); + break; + case "size": + prompt = vscode.l10n.t("Enter size filter"); + placeholder = vscode.l10n.t("e.g., +1M (larger than 1MB), -500K (smaller than 500KB), 100G"); + break; + case "perm": + prompt = vscode.l10n.t("Enter permission octal mask"); + placeholder = vscode.l10n.t("e.g., 755, -644 (not 644)"); + break; + case "type": + prompt = vscode.l10n.t("Enter file type"); + placeholder = vscode.l10n.t("c, d, f, l, p, or s"); + break; + case "depth": + prompt = vscode.l10n.t("Enter directory depth"); + placeholder = vscode.l10n.t("e.g., 2 (search 2 levels deep)"); + break; + } - if (inputValue != null && inputValue.trim()) { - if (filter.inputType === "number") { - (newFilterOptions as any)[filter.key] = parseInt(inputValue); - } else { - (newFilterOptions as any)[filter.key] = inputValue.trim(); + const inputValue = await Gui.showInputBox({ + prompt, + placeHolder: placeholder, + value: currentValue?.toString() || "", + validateInput: (value) => { + if (!value.trim()) { + return vscode.l10n.t("Value cannot be empty"); + } + if (filter.inputType === "number" && isNaN(parseInt(value))) { + return vscode.l10n.t("Must be a valid number"); } + return null; + }, + }); + + if (inputValue != null && inputValue.trim()) { + if (filter.inputType === "number") { + (newFilterOptions as any)[filter.key] = parseInt(inputValue); + } else { + (newFilterOptions as any)[filter.key] = inputValue.trim(); } } } @@ -500,19 +482,50 @@ export class USSActions { } private static async getUssDownloadOptions(node: IZoweUSSTreeNode, isDirectory: boolean = false): Promise { - const ussDownloadOptions: Definitions.UssDownloadOptions = + const downloadOpts: Definitions.UssDownloadOptions = ZoweLocalStorage.getValue(Definitions.LocalStorageKey.USS_DOWNLOAD_OPTIONS) ?? {}; - ussDownloadOptions.overwrite ??= false; - ussDownloadOptions.generateDirectory ??= false; - ussDownloadOptions.includeHidden ??= false; - ussDownloadOptions.chooseEncoding ??= false; - ussDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); - ussDownloadOptions.dirFilterOptions ??= {}; + downloadOpts.overwrite ??= false; + downloadOpts.generateDirectory ??= false; + downloadOpts.chooseEncoding ??= false; + downloadOpts.selectedPath ??= LocalFileManagement.getDefaultUri(); + downloadOpts.dirOptions ??= {}; + downloadOpts.dirOptions.includeHidden ??= false; + downloadOpts.dirOptions.chooseFilterOptions ??= false; + downloadOpts.dirOptions.filesys ??= false; + downloadOpts.dirOptions.symlinks ??= false; + downloadOpts.dirFilterOptions ??= {}; + + if (downloadOpts.encoding && USSUtils.zosEncodingToString(downloadOpts.encoding) == "text") { + downloadOpts.encoding = undefined; + } + + const getEncodingDescription = (): string => { + if (isDirectory) { + const currentEncoding = downloadOpts.dirOptions.directoryEncoding; + if (!currentEncoding || currentEncoding === "auto-detect") { + return vscode.l10n.t("Select default encoding for directory files (current: Auto-detect from file tags)"); + } - if (USSUtils.zosEncodingToString(ussDownloadOptions.encoding) == "text") { - ussDownloadOptions.encoding = undefined; - } + const encodingName = + currentEncoding.kind === "binary" ? "binary" : currentEncoding.kind === "other" ? currentEncoding.codepage : "EBCDIC"; + + return vscode.l10n.t("Select default encoding for directory files (current: {0})", encodingName); + } else { + if (!downloadOpts.encoding) { + return vscode.l10n.t("Select specific encoding for file"); + } + + const encodingName = + downloadOpts.encoding.kind === "binary" + ? "binary" + : downloadOpts.encoding.kind === "other" + ? downloadOpts.encoding.codepage + : "EBCDIC"; + + return vscode.l10n.t("Select specific encoding for file (current: {0})", encodingName); + } + }; const optionItems: vscode.QuickPickItem[] = [ { @@ -520,33 +533,12 @@ export class USSActions { description: isDirectory ? vscode.l10n.t("Overwrite existing files when downloading directories") : vscode.l10n.t("Overwrite existing file"), - picked: ussDownloadOptions.overwrite, + picked: downloadOpts.overwrite, }, { label: vscode.l10n.t("Generate Directory Structure"), description: vscode.l10n.t("Generates sub-folders based on the USS path"), - picked: ussDownloadOptions.generateDirectory, - }, - { - label: vscode.l10n.t("Choose Encoding"), - description: ussDownloadOptions.encoding - ? vscode.l10n.t({ - message: isDirectory - ? "Select default encoding for directory files (current: {0})" - : "Select specific encoding for file (current: {0})", - args: [ - ussDownloadOptions.encoding.kind === "binary" - ? "binary" - : ussDownloadOptions.encoding.kind === "other" - ? ussDownloadOptions.encoding.codepage - : "default", - ], - comment: ["Encoding kind or codepage"], - }) - : isDirectory - ? vscode.l10n.t("Select default encoding for directory files or auto-detect from file tags") - : vscode.l10n.t("Select specific encoding for file"), - picked: ussDownloadOptions.chooseEncoding, + picked: downloadOpts.generateDirectory, }, ]; @@ -555,20 +547,37 @@ export class USSActions { optionItems.push( { label: vscode.l10n.t("Include Hidden Files"), - description: vscode.l10n.t("Include hidden files (those starting with a dot) when downloading directories"), - picked: ussDownloadOptions.includeHidden, + description: vscode.l10n.t("Include hidden files when downloading directories"), + picked: downloadOpts.dirOptions.includeHidden, + }, + { + label: vscode.l10n.t("Search All Filesystems"), + description: vscode.l10n.t("Search all filesystems under the path (not just same filesystem)"), + picked: downloadOpts.dirOptions.filesys, + }, + { + label: vscode.l10n.t("Return Symlinks"), + description: vscode.l10n.t("Return symbolic links instead of following them"), + picked: downloadOpts.dirOptions.symlinks, }, { label: vscode.l10n.t("Set Filter Options"), description: - ussDownloadOptions.dirFilterOptions && Object.keys(ussDownloadOptions.dirFilterOptions).length > 0 + downloadOpts.dirFilterOptions && Object.keys(downloadOpts.dirFilterOptions).length > 0 ? vscode.l10n.t("Configure file filtering options (currently configured)") : vscode.l10n.t("Configure file filtering options"), - picked: false, + picked: downloadOpts.dirOptions.chooseFilterOptions, } ); } + // Put this here because it should be at the bottom of the quick pick for both files and directories + optionItems.push({ + label: vscode.l10n.t("Choose Encoding"), + description: getEncodingDescription(), + picked: downloadOpts.chooseEncoding, + }); + const optionsQuickPick = Gui.createQuickPick(); optionsQuickPick.title = vscode.l10n.t("Download Options"); optionsQuickPick.placeholder = vscode.l10n.t("Select download options"); @@ -602,38 +611,52 @@ export class USSActions { } const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); - ussDownloadOptions.overwrite = getOption("Overwrite"); - ussDownloadOptions.generateDirectory = getOption("Generate Directory Structure"); - ussDownloadOptions.chooseEncoding = getOption("Choose Encoding"); + downloadOpts.overwrite = getOption("Overwrite"); + downloadOpts.generateDirectory = getOption("Generate Directory Structure"); + downloadOpts.chooseEncoding = getOption("Choose Encoding"); // Only set directory-specific options when downloading directories if (isDirectory) { - ussDownloadOptions.includeHidden = getOption("Include Hidden Files"); + downloadOpts.dirOptions.includeHidden = getOption("Include Hidden Files"); + downloadOpts.dirOptions.filesys = getOption("Search All Filesystems"); + downloadOpts.dirOptions.symlinks = getOption("Return Symlinks"); + downloadOpts.dirOptions.chooseFilterOptions = getOption("Set Filter Options"); - // Handle filter options configuration if (getOption("Set Filter Options")) { - const filterOptions = await USSActions.getUssDirFilterOptions(ussDownloadOptions.dirFilterOptions); + const filterOptions = await USSActions.getUssDirFilterOptions(downloadOpts.dirFilterOptions); if (filterOptions === null) { return; } - ussDownloadOptions.dirFilterOptions = filterOptions; + downloadOpts.dirFilterOptions = filterOptions; } } - if (ussDownloadOptions.chooseEncoding) { - const ussApi = ZoweExplorerApiRegister.getUssApi(node.getProfile()); - let taggedEncoding: string; + if (downloadOpts.chooseEncoding) { + if (isDirectory) { + const profile = node.getProfile(); - // Only get tagged encoding for files, not directories - if (ussApi.getTag != null && !isDirectory) { - taggedEncoding = await ussApi.getTag(node.fullPath); - } + downloadOpts.dirOptions.directoryEncoding = await SharedUtils.promptForDirectoryEncoding( + profile, + node.fullPath, + downloadOpts.dirOptions.directoryEncoding + ); - ussDownloadOptions.encoding = await SharedUtils.promptForEncoding(node, taggedEncoding !== "untagged" ? taggedEncoding : undefined); - if (ussDownloadOptions.encoding === undefined) { - return; + if (downloadOpts.dirOptions.directoryEncoding === undefined) { + return; + } + } else { + const ussApi = ZoweExplorerApiRegister.getUssApi(node.getProfile()); + let taggedEncoding: string; + + if (ussApi.getTag != null) { + taggedEncoding = await ussApi.getTag(node.fullPath); + } + + downloadOpts.encoding = await SharedUtils.promptForEncoding(node, taggedEncoding !== "untagged" ? taggedEncoding : undefined); + if (downloadOpts.encoding === undefined) { + return; + } } - // Note: ussDownloadOptions.encoding can be null for auto-detect mode (directories only) } const dialogOptions: vscode.OpenDialogOptions = { @@ -641,7 +664,7 @@ export class USSActions { canSelectFolders: true, canSelectMany: false, openLabel: vscode.l10n.t("Select Download Location"), - defaultUri: ussDownloadOptions.selectedPath, + defaultUri: downloadOpts.selectedPath, }; const downloadPath = await Gui.showOpenDialog(dialogOptions); @@ -650,10 +673,10 @@ export class USSActions { } const selectedPath = downloadPath[0].fsPath; - ussDownloadOptions.selectedPath = vscode.Uri.file(selectedPath); - await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.USS_DOWNLOAD_OPTIONS, ussDownloadOptions); + downloadOpts.selectedPath = vscode.Uri.file(selectedPath); + await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.USS_DOWNLOAD_OPTIONS, downloadOpts); - return ussDownloadOptions; + return downloadOpts; } public static async downloadUssFile(node: IZoweUSSTreeNode): Promise { @@ -750,17 +773,20 @@ export class USSActions { ? path.join(downloadOptions.selectedPath.fsPath, node.fullPath) : downloadOptions.selectedPath.fsPath, overwrite: downloadOptions.overwrite, - includeHidden: downloadOptions.includeHidden, + includeHidden: downloadOptions.dirOptions.includeHidden, maxConcurrentRequests: profile?.profile?.maxConcurrentRequests || 1, task, responseTimeout: profile?.profile?.responseTimeout, ...downloadOptions.dirFilterOptions, }; - // Only set encoding/binary if user chose a specific encoding (not auto-detect) - if (downloadOptions.encoding !== null) { - options.binary = downloadOptions.encoding?.kind === "binary"; - options.encoding = downloadOptions.encoding?.kind === "other" ? downloadOptions.encoding.codepage : profile.profile?.encoding; + // only set encoding/binary if user chose a specific encoding (not auto detect) + if (downloadOptions.dirOptions.directoryEncoding && downloadOptions.dirOptions.directoryEncoding !== "auto-detect") { + options.binary = downloadOptions.dirOptions.directoryEncoding.kind === "binary"; + options.encoding = + downloadOptions.dirOptions.directoryEncoding.kind === "other" + ? downloadOptions.dirOptions.directoryEncoding.codepage + : profile.profile?.encoding; } try { @@ -769,6 +795,9 @@ export class USSActions { return; } + // TODO: If the commandResponse contains warnings about files that could not be downloaded, + // such as files already exists (when overwrite is false), user should see + // also this for file downloads and in data set downloads await ZoweExplorerApiRegister.getUssApi(profile).downloadDirectory(node.fullPath, options); Gui.showMessage(vscode.l10n.t("Directory downloaded successfully")); } catch (e) { diff --git a/packages/zowe-explorer/src/trees/uss/USSUtils.ts b/packages/zowe-explorer/src/trees/uss/USSUtils.ts index 0a13c4eb01..d9c53da7ac 100644 --- a/packages/zowe-explorer/src/trees/uss/USSUtils.ts +++ b/packages/zowe-explorer/src/trees/uss/USSUtils.ts @@ -17,6 +17,8 @@ import type { ZoweUSSNode } from "./ZoweUSSNode"; import { ZoweExplorerApiRegister } from "../../extending/ZoweExplorerApiRegister"; import { ZoweLogger } from "../../tools/ZoweLogger"; import { SharedContext } from "../shared/SharedContext"; +import { SharedTreeProviders } from "../shared/SharedTreeProviders"; +import { Constants } from "../../configuration/Constants"; export class USSUtils { /** @@ -121,7 +123,16 @@ export class USSUtils { ZoweLogger.trace("uss.actions.countAllFilesRecursively called."); let totalCount = 0; + // Return early to avoid unnecessary recursion + if (totalCount > Constants.MIN_WARN_DOWNLOAD_FILES) { + return totalCount; + } + try { + // Force the node to refresh its children, because after downloading a node, + // the tree seems to uncollapse at that node and is not marked as dirty + (node as any).dirty = true; + const children = await node.getChildren(); if (!children || children.length === 0) { return 0; From 10a1ce753e5ccf81ead54e66ec880300bcdc454b Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Mon, 20 Oct 2025 12:29:44 +0100 Subject: [PATCH 26/29] refactor: handle download responses properly Signed-off-by: JWaters02 --- .../src/trees/dataset/DatasetActions.ts | 12 +-- .../src/trees/shared/SharedUtils.ts | 94 +++++++++++++++++++ .../zowe-explorer/src/trees/uss/USSActions.ts | 11 +-- .../zowe-explorer/src/trees/uss/USSUtils.ts | 1 - 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts index ab055f9751..eb084ef291 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -724,7 +724,7 @@ export class DatasetActions { private static async executeDownloadWithProgress( title: string, downloadFn: (progress?: vscode.Progress<{ message?: string; increment?: number }>) => Promise, - successMessage: string, + downloadType: string, node: IZoweDatasetTreeNode ): Promise { await Gui.withProgress( @@ -735,8 +735,8 @@ export class DatasetActions { }, async (progress) => { try { - await downloadFn(progress); - Gui.showMessage(successMessage); + const response = await downloadFn(progress); + void SharedUtils.handleDownloadResponse(response, downloadType); } catch (e) { await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); } @@ -825,7 +825,7 @@ export class DatasetActions { await ZoweExplorerApiRegister.getMvsApi(profile).downloadAllMembers(datasetName, downloadOptions); }, - vscode.l10n.t("Dataset downloaded successfully"), + vscode.l10n.t("Data set members"), node ); } @@ -877,7 +877,7 @@ export class DatasetActions { await ZoweExplorerApiRegister.getMvsApi(profile).getContents(fullDatasetName, downloadOptions); }, - vscode.l10n.t("Member downloaded successfully"), + vscode.l10n.t("Data set member"), node ); } @@ -935,7 +935,7 @@ export class DatasetActions { await ZoweExplorerApiRegister.getMvsApi(profile).getContents(datasetName, downloadOptions); }, - vscode.l10n.t("Data set downloaded successfully"), + vscode.l10n.t("Data set"), node ); } diff --git a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts index f16deb70d2..c2cfa9c296 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts @@ -23,6 +23,7 @@ import { ZosEncoding, Sorting, imperative, + CorrelatedError, } from "@zowe/zowe-explorer-api"; import { UssFSProvider } from "../uss/UssFSProvider"; import { USSUtils } from "../uss/USSUtils"; @@ -672,4 +673,97 @@ export class SharedUtils { } } } + + /** + * Handles download response and provides detailed feedback to user about successes, warnings, and failures + * + * @param response The response from the download API + * @param downloadType The type of download (File, Directory, Data set, etc.) + */ + public static async handleDownloadResponse(response: any, downloadType: string): Promise { + ZoweLogger.trace("SharedUtils.handleDownloadResponse called."); + + if (!response) { + Gui.showMessage(vscode.l10n.t("{0} download completed.", downloadType)); + return; + } + + let message = ""; + let hasWarnings = false; + let hasErrors = false; + const detailedInfo: string[] = []; + + if (response.success === false) { + hasErrors = true; + message = vscode.l10n.t("{0} download completed with errors.", downloadType); + } else { + message = vscode.l10n.t("{0} downloaded successfully.", downloadType); + } + + if (response.commandResponse) { + const commandResponse = response.commandResponse.toString(); + + if (commandResponse.includes("already exists") || commandResponse.includes("skipped")) { + hasWarnings = true; + detailedInfo.push("Some files were skipped because they already exist."); + } + + if (commandResponse.includes("failed") || commandResponse.includes("error")) { + hasErrors = true; + detailedInfo.push("Some files failed to download due to errors."); + } + + ZoweLogger.info(`Download response details: ${String(commandResponse)}`); + detailedInfo.push(`Full response: ${String(commandResponse)}`); + } + + if (response.apiResponse && Array.isArray(response.apiResponse)) { + const failedItems = response.apiResponse.filter((item: any) => item.error || item.status === "failed"); + if (failedItems.length > 0) { + hasErrors = true; + const failedCount = String(failedItems.length); + ZoweLogger.warn(`${failedCount} items failed to download: ${JSON.stringify(failedItems)}`); + detailedInfo.push(`${failedCount} items failed to download`); + detailedInfo.push(`Failed items: ${JSON.stringify(failedItems, null, 2)}`); + } + } + + if (hasErrors) { + const errorMessage = vscode.l10n.t("{0}\n\nSome files may not have been downloaded.", message); + const correlatedError = new CorrelatedError({ + initialError: new Error(errorMessage), + correlation: { + summary: `${downloadType} download completed with errors`, + }, + }); + + await Gui.errorMessage(errorMessage, { + items: [vscode.l10n.t("View Details")], + vsCodeOpts: { modal: false }, + }).then((selection) => { + if (selection === vscode.l10n.t("View Details")) { + vscode.commands.executeCommand("zowe.troubleshootError", correlatedError, detailedInfo.join("\n\n")); + } + }); + } else if (hasWarnings) { + const warningMessage = vscode.l10n.t("{0}\n\nSome files may have been skipped.", message); + const correlatedWarning = new CorrelatedError({ + initialError: new Error(warningMessage), + correlation: { + summary: `${downloadType} download completed with warnings`, + }, + }); + + await Gui.warningMessage(warningMessage, { + items: [vscode.l10n.t("View Details")], + vsCodeOpts: { modal: false }, + }).then((selection) => { + if (selection === vscode.l10n.t("View Details")) { + vscode.commands.executeCommand("zowe.troubleshootError", correlatedWarning, detailedInfo.join("\n\n")); + } + }); + } else { + Gui.showMessage(message); + } + } } diff --git a/packages/zowe-explorer/src/trees/uss/USSActions.ts b/packages/zowe-explorer/src/trees/uss/USSActions.ts index d5c083a7c0..3d7124f98f 100644 --- a/packages/zowe-explorer/src/trees/uss/USSActions.ts +++ b/packages/zowe-explorer/src/trees/uss/USSActions.ts @@ -707,8 +707,8 @@ export class USSActions { }; try { - await ZoweExplorerApiRegister.getUssApi(profile).getContents(node.fullPath, options); - Gui.showMessage(vscode.l10n.t("File downloaded successfully")); + const response = await ZoweExplorerApiRegister.getUssApi(profile).getContents(node.fullPath, options); + void SharedUtils.handleDownloadResponse(response, vscode.l10n.t("USS file")); } catch (e) { await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile }); } @@ -795,11 +795,8 @@ export class USSActions { return; } - // TODO: If the commandResponse contains warnings about files that could not be downloaded, - // such as files already exists (when overwrite is false), user should see - // also this for file downloads and in data set downloads - await ZoweExplorerApiRegister.getUssApi(profile).downloadDirectory(node.fullPath, options); - Gui.showMessage(vscode.l10n.t("Directory downloaded successfully")); + const response = await ZoweExplorerApiRegister.getUssApi(profile).downloadDirectory(node.fullPath, options); + void SharedUtils.handleDownloadResponse(response, vscode.l10n.t("USS directory")); } catch (e) { await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Uss, profile }); } diff --git a/packages/zowe-explorer/src/trees/uss/USSUtils.ts b/packages/zowe-explorer/src/trees/uss/USSUtils.ts index d9c53da7ac..b98f42ae3a 100644 --- a/packages/zowe-explorer/src/trees/uss/USSUtils.ts +++ b/packages/zowe-explorer/src/trees/uss/USSUtils.ts @@ -17,7 +17,6 @@ import type { ZoweUSSNode } from "./ZoweUSSNode"; import { ZoweExplorerApiRegister } from "../../extending/ZoweExplorerApiRegister"; import { ZoweLogger } from "../../tools/ZoweLogger"; import { SharedContext } from "../shared/SharedContext"; -import { SharedTreeProviders } from "../shared/SharedTreeProviders"; import { Constants } from "../../configuration/Constants"; export class USSUtils { From a61fa6f5e63a13a961a4f64ff6780b13fb1bd673 Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Mon, 20 Oct 2025 17:36:26 +0100 Subject: [PATCH 27/29] tests: fix tests and add new tests for the changes Signed-off-by: JWaters02 --- .../trees/dataset/DatasetActions.unit.test.ts | 17 +- .../trees/shared/SharedUtils.unit.test.ts | 406 +++++++++++++++- .../trees/uss/USSActions.unit.test.ts | 432 +++++++++++++++++- .../__unit__/trees/uss/USSUtils.unit.test.ts | 59 +++ .../zowe-explorer/src/trees/uss/USSActions.ts | 30 +- .../zowe-explorer/src/trees/uss/USSUtils.ts | 12 +- 6 files changed, 912 insertions(+), 44 deletions(-) diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts index e69a49cdac..e9558bdb3f 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetActions.unit.test.ts @@ -3857,8 +3857,9 @@ describe("DatasetActions - downloading functions", () => { DatasetActions, "executeDownloadWithProgress" as any, undefined, - jest.fn().mockImplementation(async (_title, downloadFn, _successMessage, _node) => { - await downloadFn(); + jest.fn().mockImplementation(async (_title, downloadFn, _downloadType, _node) => { + const response = await downloadFn(); + return response; }) ); @@ -3868,6 +3869,7 @@ describe("DatasetActions - downloading functions", () => { new MockedProperty(Gui, "errorMessage", undefined, jest.fn()); new MockedProperty(ZoweLogger, "trace", undefined, jest.fn()); new MockedProperty(ZoweLocalStorage, "getValue", undefined, jest.fn()); + new MockedProperty(SharedUtils, "handleDownloadResponse", undefined, jest.fn().mockResolvedValue(undefined)); }); it("should successfully download all members of a PDS", async () => { @@ -3898,7 +3900,7 @@ describe("DatasetActions - downloading functions", () => { expect(mockExecuteDownloadWithProgress.mock).toHaveBeenCalledWith( "Downloading all members", expect.any(Function), - "Dataset downloaded successfully", + "Data set members", pdsNode ); }); @@ -4094,7 +4096,7 @@ describe("DatasetActions - downloading functions", () => { expect(mockExecuteDownloadWithProgress.mock).toHaveBeenCalledWith( "Downloading member", expect.any(Function), - "Member downloaded successfully", + "Data set member", memberNode ); }); @@ -4233,12 +4235,7 @@ describe("DatasetActions - downloading functions", () => { await DatasetActions.downloadDataSet(dsNode); expect(mockGetDataSetDownloadOptions.mock).toHaveBeenCalled(); - expect(mockExecuteDownloadWithProgress.mock).toHaveBeenCalledWith( - "Downloading data set", - expect.any(Function), - "Data set downloaded successfully", - dsNode - ); + expect(mockExecuteDownloadWithProgress.mock).toHaveBeenCalledWith("Downloading data set", expect.any(Function), "Data set", dsNode); const downloadFn = mockExecuteDownloadWithProgress.mock.mock.calls[0][1]; await downloadFn(); diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedUtils.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedUtils.unit.test.ts index 963cde5e57..961593436a 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedUtils.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedUtils.unit.test.ts @@ -14,7 +14,7 @@ import { createIProfile, createISession, createInstanceOfProfile } from "../../. import { createDatasetSessionNode } from "../../../__mocks__/mockCreators/datasets"; import { createUSSNode, createUSSSessionNode } from "../../../__mocks__/mockCreators/uss"; import { UssFSProvider } from "../../../../src/trees/uss/UssFSProvider"; -import { imperative, ProfilesCache, Gui, ZosEncoding, BaseProvider, Sorting } from "@zowe/zowe-explorer-api"; +import { imperative, ProfilesCache, Gui, ZosEncoding, BaseProvider, Sorting, CorrelatedError } from "@zowe/zowe-explorer-api"; import { Constants } from "../../../../src/configuration/Constants"; import { SettingsConfig } from "../../../../src/configuration/SettingsConfig"; import { FilterItem } from "../../../../src/management/FilterManagement"; @@ -1514,3 +1514,407 @@ describe("SharedUtils.handleProfileChange", () => { expect(errorSpy).toHaveBeenCalledWith("error while updating profile on node"); }); }); + +describe("Shared utils unit tests - function promptForDirectoryEncoding", () => { + const binaryEncoding: ZosEncoding = { kind: "binary" }; + const textEncoding: ZosEncoding = { kind: "text" }; + const otherEncoding: ZosEncoding = { kind: "other", codepage: "IBM-1047" }; + + function createBlockMocks() { + const showInputBox = jest.spyOn(Gui, "showInputBox").mockResolvedValue(undefined); + const showQuickPick = jest.spyOn(Gui, "showQuickPick").mockResolvedValue(undefined); + const localStorageGet = jest.spyOn(ZoweLocalStorage, "getValue").mockReturnValue(undefined); + const localStorageSet = jest.spyOn(ZoweLocalStorage, "setValue").mockResolvedValue(undefined); + const infoMessage = jest.spyOn(Gui, "infoMessage").mockResolvedValue(undefined); + + return { + profile: createIProfile(), + showInputBox, + showQuickPick, + localStorageGet, + localStorageSet, + infoMessage, + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it("returns auto-detect when Auto-detect option is selected", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("Auto-detect from file tags") }); + + const result = await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path"); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + expect(result).toBe("auto-detect"); + }); + + it("returns text encoding when EBCDIC is selected", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("EBCDIC") }); + + const result = await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path"); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + expect(result).toEqual(textEncoding); + }); + + it("returns binary encoding when Binary is selected", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("Binary") }); + + const result = await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path"); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + expect(result).toEqual(binaryEncoding); + }); + + it("returns other encoding when Other is selected and codepage is provided", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("Other") }); + blockMocks.showInputBox.mockResolvedValueOnce("IBM-1047"); + + const result = await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path"); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + expect(blockMocks.showInputBox).toHaveBeenCalled(); + expect(result).toEqual(otherEncoding); + }); + + it("returns undefined when Other is selected but no codepage is provided", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("Other") }); + blockMocks.showInputBox.mockResolvedValueOnce(undefined); + + const result = await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path"); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + expect(blockMocks.showInputBox).toHaveBeenCalled(); + expect(blockMocks.infoMessage).toHaveBeenCalledWith(vscode.l10n.t("Operation cancelled")); + expect(result).toBeUndefined(); + }); + + it("returns undefined when quick pick is cancelled", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce(undefined); + + const result = await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path"); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it("handles current directory encoding as auto-detect", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("Binary") }); + + await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path", "auto-detect"); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + const callArgs = blockMocks.showQuickPick.mock.calls[0][1]; + expect(callArgs?.placeHolder).toContain(vscode.l10n.t("Auto-detect from file tags")); + }); + + it("handles current directory encoding as binary", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("EBCDIC") }); + + await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path", binaryEncoding); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + const callArgs = blockMocks.showQuickPick.mock.calls[0][1]; + expect(callArgs?.placeHolder).toContain(vscode.l10n.t("Binary")); + }); + + it("handles current directory encoding as text", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("EBCDIC") }); + + await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path", textEncoding); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + const callArgs = blockMocks.showQuickPick.mock.calls[0][1]; + expect(callArgs?.placeHolder).toContain(vscode.l10n.t("EBCDIC")); + }); + + it("handles current directory encoding as other codepage", async () => { + const blockMocks = createBlockMocks(); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("EBCDIC") }); + + await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path", otherEncoding); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + const callArgs = blockMocks.showQuickPick.mock.calls[0][1]; + expect(callArgs?.placeHolder).toContain("OTHER-IBM-1047"); + }); + + it("handles profile encoding in options", async () => { + const blockMocks = createBlockMocks(); + (blockMocks.profile.profile as any).encoding = "IBM-1047"; + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("EBCDIC") }); + + await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path"); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + const items = await blockMocks.showQuickPick.mock.calls[0][0]; + expect(Array.isArray(items) && items.some((item: any) => item.label === "IBM-1047" && item.description.includes("From profile"))).toBe(true); + }); + + it("includes encoding history in options", async () => { + const blockMocks = createBlockMocks(); + const encodingHistory = ["IBM-1147", "UTF-8"]; + blockMocks.localStorageGet.mockReturnValueOnce(encodingHistory); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: encodingHistory[0] }); + + const result = await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path"); + + expect(blockMocks.showQuickPick).toHaveBeenCalled(); + const items = await blockMocks.showQuickPick.mock.calls[0][0]; + expect(Array.isArray(items) && items.some((item: any) => item.label === "IBM-1147")).toBe(true); + expect(result).toEqual({ kind: "other", codepage: "IBM-1147" }); + }); + + it("saves custom encoding to history", async () => { + const blockMocks = createBlockMocks(); + const existingHistory = ["IBM-1147"]; + blockMocks.localStorageGet.mockReturnValueOnce(existingHistory); + blockMocks.showQuickPick.mockResolvedValueOnce({ label: vscode.l10n.t("Other") }); + blockMocks.showInputBox.mockResolvedValueOnce("UTF-8"); + + await SharedUtils.promptForDirectoryEncoding(blockMocks.profile, "/test/path"); + + expect(blockMocks.localStorageSet).toHaveBeenCalledWith(expect.anything(), expect.arrayContaining(["UTF-8"])); + }); +}); + +describe("Shared utils unit tests - function handleDownloadResponse", () => { + function createBlockMocks() { + const showMessage = jest.spyOn(Gui, "showMessage").mockResolvedValue(undefined); + const errorMessage = jest.spyOn(Gui, "errorMessage").mockResolvedValue(undefined); + const warningMessage = jest.spyOn(Gui, "warningMessage").mockResolvedValue(undefined); + const loggerTrace = jest.spyOn(ZoweLogger, "trace").mockReturnValue(undefined); + const loggerInfo = jest.spyOn(ZoweLogger, "info").mockReturnValue(undefined); + const loggerWarn = jest.spyOn(ZoweLogger, "warn").mockReturnValue(undefined); + const executeCommand = jest.spyOn(vscode.commands, "executeCommand").mockResolvedValue(undefined); + + return { + showMessage, + errorMessage, + warningMessage, + loggerTrace, + loggerInfo, + loggerWarn, + executeCommand, + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it("shows simple success message when response is falsy", async () => { + const blockMocks = createBlockMocks(); + + await SharedUtils.handleDownloadResponse(null, "File"); + + expect(blockMocks.showMessage).toHaveBeenCalledWith(vscode.l10n.t("{0} download completed.", "File")); + }); + + it("shows simple success message when response is undefined", async () => { + const blockMocks = createBlockMocks(); + + await SharedUtils.handleDownloadResponse(undefined, "Directory"); + + expect(blockMocks.showMessage).toHaveBeenCalledWith(vscode.l10n.t("{0} download completed.", "Directory")); + }); + + it("shows simple success message for successful response without warnings", async () => { + const blockMocks = createBlockMocks(); + const response = { success: true }; + + await SharedUtils.handleDownloadResponse(response, "Data set"); + + expect(blockMocks.showMessage).toHaveBeenCalledWith(vscode.l10n.t("{0} downloaded successfully.", "Data set")); + }); + + it("shows error message when success is false", async () => { + const blockMocks = createBlockMocks(); + const response = { success: false }; + + await SharedUtils.handleDownloadResponse(response, "File"); + + const errorMsg = vscode.l10n.t("{0} download completed with errors.", "File"); + const fullErrorMsg = vscode.l10n.t("{0}\n\nSome files may not have been downloaded.", errorMsg); + expect(blockMocks.errorMessage).toHaveBeenCalledWith( + fullErrorMsg, + expect.objectContaining({ + items: [vscode.l10n.t("View Details")], + vsCodeOpts: { modal: false }, + }) + ); + }); + + it("shows warning message when commandResponse contains 'already exists'", async () => { + const blockMocks = createBlockMocks(); + const response = { + success: true, + commandResponse: "file already exists and was skipped", + }; + + await SharedUtils.handleDownloadResponse(response, "File"); + + expect(blockMocks.loggerInfo).toHaveBeenCalledWith("Download response details: file already exists and was skipped"); + const successMsg = vscode.l10n.t("{0} downloaded successfully.", "File"); + const warningMsg = vscode.l10n.t("{0}\n\nSome files may have been skipped.", successMsg); + expect(blockMocks.warningMessage).toHaveBeenCalledWith( + warningMsg, + expect.objectContaining({ + items: [vscode.l10n.t("View Details")], + vsCodeOpts: { modal: false }, + }) + ); + }); + + it("shows warning message when commandResponse contains 'skipped'", async () => { + const blockMocks = createBlockMocks(); + const response = { + success: true, + commandResponse: "some files were skipped", + }; + + await SharedUtils.handleDownloadResponse(response, "Directory"); + + expect(blockMocks.warningMessage).toHaveBeenCalled(); + }); + + it("shows error message when commandResponse contains 'failed'", async () => { + const blockMocks = createBlockMocks(); + const response = { + success: true, + commandResponse: "download failed for some items", + }; + + await SharedUtils.handleDownloadResponse(response, "File"); + + expect(blockMocks.loggerInfo).toHaveBeenCalledWith("Download response details: download failed for some items"); + expect(blockMocks.errorMessage).toHaveBeenCalled(); + }); + + it("shows error message when commandResponse contains 'error'", async () => { + const blockMocks = createBlockMocks(); + const response = { + success: true, + commandResponse: "an error occurred during download", + }; + + await SharedUtils.handleDownloadResponse(response, "File"); + + expect(blockMocks.errorMessage).toHaveBeenCalled(); + }); + + it("handles apiResponse with failed items", async () => { + const blockMocks = createBlockMocks(); + const failedItems = [ + { name: "file1.txt", error: "Permission denied" }, + { name: "file2.txt", status: "failed" }, + ]; + const response = { + success: true, + apiResponse: [{ name: "file3.txt", status: "success" }, ...failedItems], + }; + + await SharedUtils.handleDownloadResponse(response, "Directory"); + + expect(blockMocks.loggerWarn).toHaveBeenCalledWith(`2 items failed to download: ${JSON.stringify(failedItems)}`); + expect(blockMocks.errorMessage).toHaveBeenCalled(); + }); + + it("handles both commandResponse warnings and apiResponse errors", async () => { + const blockMocks = createBlockMocks(); + const response = { + success: true, + commandResponse: "some files were skipped", + apiResponse: [ + { name: "file1.txt", error: "Failed" }, + { name: "file2.txt", status: "success" }, + ], + }; + + await SharedUtils.handleDownloadResponse(response, "File"); + + expect(blockMocks.errorMessage).toHaveBeenCalled(); + }); + + it("executes troubleshoot command when View Details is selected for errors", async () => { + const blockMocks = createBlockMocks(); + blockMocks.errorMessage.mockResolvedValueOnce(vscode.l10n.t("View Details")); + const response = { success: false }; + + await SharedUtils.handleDownloadResponse(response, "File"); + + expect(blockMocks.executeCommand).toHaveBeenCalledWith("zowe.troubleshootError", expect.any(CorrelatedError), expect.any(String)); + }); + + it("executes troubleshoot command when View Details is selected for warnings", async () => { + const blockMocks = createBlockMocks(); + blockMocks.warningMessage.mockImplementation((_msg, _options) => { + return Promise.resolve(vscode.l10n.t("View Details")); + }); + const response = { + success: true, + commandResponse: "file already exists", + }; + + await SharedUtils.handleDownloadResponse(response, "Directory"); + + expect(blockMocks.executeCommand).toHaveBeenCalledWith("zowe.troubleshootError", expect.any(CorrelatedError), expect.any(String)); + }); + + it("does not execute troubleshoot command when View Details is not selected", async () => { + const blockMocks = createBlockMocks(); + blockMocks.errorMessage.mockResolvedValueOnce(undefined); + const response = { success: false }; + + await SharedUtils.handleDownloadResponse(response, "File"); + + expect(blockMocks.executeCommand).not.toHaveBeenCalled(); + }); + + it("handles complex response with all types of information", async () => { + const blockMocks = createBlockMocks(); + const response = { + success: false, + commandResponse: "Some files failed to download and others were skipped because they already exist", + apiResponse: [ + { name: "file1.txt", status: "success" }, + { name: "file2.txt", error: "Permission denied" }, + { name: "file3.txt", status: "failed" }, + ], + }; + + await SharedUtils.handleDownloadResponse(response, "Directory"); + + expect(blockMocks.loggerInfo).toHaveBeenCalled(); + expect(blockMocks.loggerWarn).toHaveBeenCalled(); + expect(blockMocks.errorMessage).toHaveBeenCalled(); + }); + + it("handles numeric commandResponse correctly", async () => { + const blockMocks = createBlockMocks(); + const response = { + success: true, + commandResponse: 12345, + }; + + await SharedUtils.handleDownloadResponse(response, "File"); + + expect(blockMocks.loggerInfo).toHaveBeenCalledWith("Download response details: 12345"); + expect(blockMocks.showMessage).toHaveBeenCalledWith(vscode.l10n.t("{0} downloaded successfully.", "File")); + }); +}); diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts index 67faddb572..61fa16f0bd 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts @@ -1107,6 +1107,7 @@ describe("USS Action Unit Tests - downloading functions", () => { jest.spyOn(USSUtils, "countAllFilesRecursively").mockResolvedValue(5); jest.spyOn(SharedUtils, "promptForEncoding").mockResolvedValue({ kind: "other", codepage: "IBM-1047" }); + jest.spyOn(SharedUtils, "handleDownloadResponse").mockResolvedValue(); globalMocks.ussApi = { getTag: jest.fn().mockResolvedValue("untagged"), @@ -1120,11 +1121,387 @@ describe("USS Action Unit Tests - downloading functions", () => { jest.clearAllMocks(); }); + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + const createMockNode = (): IZoweUSSTreeNode => { const mockNode = createUSSNode(createISession(), createIProfile()) as IZoweUSSTreeNode; mockNode.fullPath = "/u/test/file.txt"; return mockNode; }; + describe("getUssDirFilterOptions", () => { + let filterQuickPick: any; + let filterShowInputBox: jest.SpyInstance; + + beforeEach(() => { + filterQuickPick = { + title: "", + placeholder: "", + ignoreFocusOut: false, + canSelectMany: false, + items: [], + selectedItems: [], + onDidAccept: jest.fn(), + onDidHide: jest.fn(), + show: jest.fn(), + hide: jest.fn(), + dispose: jest.fn(), + }; + + jest.spyOn(Gui, "createQuickPick").mockReturnValue(filterQuickPick); + filterShowInputBox = jest.spyOn(Gui, "showInputBox"); + jest.clearAllMocks(); + }); + + it("should return empty object when no filters are selected", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = []; + callback(); + }); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({}); + expect(filterQuickPick.show).toHaveBeenCalled(); + expect(filterQuickPick.dispose).toHaveBeenCalled(); + }); + + it("should return null when user cancels selection", async () => { + filterQuickPick.onDidHide.mockImplementation((callback: () => void) => { + callback(); + }); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toBeNull(); + expect(filterQuickPick.dispose).toHaveBeenCalled(); + }); + + it("should handle group filter input", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "group", inputType: "string" }]; + callback(); + }); + filterShowInputBox.mockResolvedValue("admin"); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ group: "admin" }); + expect(filterShowInputBox).toHaveBeenCalledWith({ + prompt: expect.stringContaining("group"), + placeHolder: expect.stringContaining("admin"), + value: "", + validateInput: expect.any(Function), + }); + }); + + it("should handle user filter input", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "user", inputType: "string" }]; + callback(); + }); + filterShowInputBox.mockResolvedValue("1001"); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ user: "1001" }); + expect(filterShowInputBox).toHaveBeenCalledWith({ + prompt: expect.stringContaining("user"), + placeHolder: expect.stringContaining("IBMUSER"), + value: "", + validateInput: expect.any(Function), + }); + }); + + it("should handle mtime filter input", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "mtime", inputType: "string" }]; + callback(); + }); + filterShowInputBox.mockResolvedValue("+7"); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ mtime: "+7" }); + expect(filterShowInputBox).toHaveBeenCalledWith({ + prompt: expect.stringContaining("modification time"), + placeHolder: expect.stringContaining("+7"), + value: "", + validateInput: expect.any(Function), + }); + }); + + it("should handle size filter input", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "size", inputType: "string" }]; + callback(); + }); + filterShowInputBox.mockResolvedValue("+1M"); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ size: "+1M" }); + expect(filterShowInputBox).toHaveBeenCalledWith({ + prompt: expect.stringContaining("size"), + placeHolder: expect.stringContaining("+1M"), + value: "", + validateInput: expect.any(Function), + }); + }); + + it("should handle permission filter input", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "perm", inputType: "string" }]; + callback(); + }); + filterShowInputBox.mockResolvedValue("755"); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ perm: "755" }); + expect(filterShowInputBox).toHaveBeenCalledWith({ + prompt: expect.stringContaining("permission"), + placeHolder: expect.stringContaining("755"), + value: "", + validateInput: expect.any(Function), + }); + }); + + it("should handle type filter input", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "type", inputType: "string" }]; + callback(); + }); + filterShowInputBox.mockResolvedValue("d"); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ type: "d" }); + expect(filterShowInputBox).toHaveBeenCalledWith({ + prompt: expect.stringContaining("file type"), + placeHolder: expect.stringContaining("c, d, f, l, p, or s"), + value: "", + validateInput: expect.any(Function), + }); + }); + + it("should handle depth filter input as number", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "depth", inputType: "number" }]; + callback(); + }); + filterShowInputBox.mockResolvedValue("3"); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ depth: 3 }); + expect(filterShowInputBox).toHaveBeenCalledWith({ + prompt: expect.stringContaining("depth"), + placeHolder: expect.stringContaining("2 levels"), + value: "", + validateInput: expect.any(Function), + }); + }); + + it("should handle multiple filters", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [ + { key: "user", inputType: "string" }, + { key: "depth", inputType: "number" }, + { key: "size", inputType: "string" }, + ]; + callback(); + }); + filterShowInputBox.mockResolvedValueOnce("IBMUSER").mockResolvedValueOnce("2").mockResolvedValueOnce("+100K"); + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ user: "IBMUSER", depth: 2, size: "+100K" }); + expect(filterShowInputBox).toHaveBeenCalledTimes(3); + }); + + it("should use current filter values as initial values", async () => { + const currentOptions = { + group: "ibmgroup", + mtime: "+30", + depth: 1, + }; + + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "group", inputType: "string" }]; + callback(); + }); + filterShowInputBox.mockResolvedValue("admin"); + + const result = await (USSActions as any).getUssDirFilterOptions(currentOptions); + + expect(filterShowInputBox).toHaveBeenCalledWith({ + prompt: expect.stringContaining("group"), + placeHolder: expect.stringContaining("admin"), + value: "ibmgroup", + validateInput: expect.any(Function), + }); + expect(result).toEqual({ group: "admin" }); + }); + + it("should validate empty input", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "user", inputType: "string" }]; + callback(); + }); + + filterShowInputBox.mockImplementation(({ validateInput: validator }: any) => { + const result = validator(""); + return result === null ? Promise.resolve("test") : Promise.resolve(null); + }); + + await (USSActions as any).getUssDirFilterOptions(); + + expect(filterShowInputBox).toHaveBeenCalled(); + }); + + it("should validate numeric input for depth", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [{ key: "depth", inputType: "number" }]; + callback(); + }); + + filterShowInputBox.mockImplementation(({ validateInput: validator }: any) => { + const result = validator("abc"); + return result === null ? Promise.resolve("3") : Promise.resolve(null); + }); + + await (USSActions as any).getUssDirFilterOptions(); + + expect(filterShowInputBox).toHaveBeenCalled(); + }); + + it("should skip filter when input is cancelled", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [ + { key: "user", inputType: "string" }, + { key: "group", inputType: "string" }, + ]; + callback(); + }); + filterShowInputBox.mockResolvedValueOnce("IBMUSER").mockResolvedValueOnce(null); // cancelled + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ user: "IBMUSER" }); + }); + + it("should skip filter when input is empty after trim", async () => { + filterQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + filterQuickPick.selectedItems = [ + { key: "user", inputType: "string" }, + { key: "group", inputType: "string" }, + ]; + callback(); + }); + filterShowInputBox.mockResolvedValueOnce("IBMUSER").mockResolvedValueOnce(" "); // empty after trim + + const result = await (USSActions as any).getUssDirFilterOptions(); + + expect(result).toEqual({ user: "IBMUSER" }); + }); + }); + + it("should handle directory filter options for USS directories", async () => { + const mockNode = createMockNode(); + const filterOptions = { user: "IBMUSER", depth: 2 }; + const getUssDirFilterOptionsSpy = jest.spyOn(USSActions as any, "getUssDirFilterOptions").mockResolvedValue(filterOptions); + mockZoweLocalStorage.mockReturnValue({}); + + const l10nSpy = jest.spyOn(vscode.l10n, "t").mockImplementation((options: any) => { + if (typeof options === "string") return options; + return options.message || options; + }); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [{ label: "Set Filter Options" }]; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/test/path")]); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, true); + + expect(getUssDirFilterOptionsSpy).toHaveBeenCalledWith({}); + expect(result.dirFilterOptions).toEqual(filterOptions); + + l10nSpy.mockRestore(); + getUssDirFilterOptionsSpy.mockRestore(); + }); + + it("should handle directory encoding selection for USS directories", async () => { + const mockNode = createMockNode(); + mockZoweLocalStorage.mockReturnValue({}); + + const promptForDirectoryEncodingSpy = jest + .spyOn(SharedUtils, "promptForDirectoryEncoding") + .mockResolvedValue({ kind: "other", codepage: "UTF-8" }); + + const l10nSpy = jest.spyOn(vscode.l10n, "t").mockImplementation((options: any) => { + if (typeof options === "string") return options; + return options.message || options; + }); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [{ label: "Choose Encoding" }]; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/test/path")]); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, true); + + expect(promptForDirectoryEncodingSpy).toHaveBeenCalledWith(mockNode.getProfile(), mockNode.fullPath, undefined); + expect(result.dirOptions.directoryEncoding).toEqual({ kind: "other", codepage: "UTF-8" }); + + l10nSpy.mockRestore(); + promptForDirectoryEncodingSpy.mockRestore(); + }); + + it("should handle complex directory options combination for USS", async () => { + const mockNode = createMockNode(); + const storedOptions = { + dirFilterOptions: { user: "existing" }, + }; + mockZoweLocalStorage.mockReturnValue(storedOptions); + const filterOptions = { user: "IBMUSER", group: "ibmgroup" }; + const getUssDirFilterOptionsSpy = jest.spyOn(USSActions as any, "getUssDirFilterOptions").mockResolvedValue(filterOptions); + + const l10nSpy = jest.spyOn(vscode.l10n, "t").mockImplementation((options: any) => { + if (typeof options === "string") return options; + return options.message || options; + }); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [ + { label: "Include Hidden Files" }, + { label: "Search All Filesystems" }, + { label: "Return Symlinks" }, + { label: "Set Filter Options" }, + ]; + callback(); + }); + + mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/test/path")]); + + const result = await (USSActions as any).getUssDownloadOptions(mockNode, true); + + expect(result.dirOptions.includeHidden).toBe(true); + expect(result.dirOptions.filesys).toBe(true); + expect(result.dirOptions.symlinks).toBe(true); + expect(result.dirFilterOptions).toEqual(filterOptions); + + l10nSpy.mockRestore(); + getUssDirFilterOptionsSpy.mockRestore(); + }); describe("getUssDownloadOptions", () => { it("should return default options when no stored values exist for file download", async () => { @@ -1143,9 +1520,15 @@ describe("USS Action Unit Tests - downloading functions", () => { expect(result).toEqual({ overwrite: false, generateDirectory: true, - includeHidden: false, chooseEncoding: false, selectedPath: vscode.Uri.file("/user/selected/path"), + dirOptions: { + includeHidden: false, + filesys: false, + symlinks: false, + chooseFilterOptions: false, + }, + dirFilterOptions: {}, }); expect(mockQuickPick.show).toHaveBeenCalled(); expect(mockShowOpenDialog).toHaveBeenCalledWith({ @@ -1175,7 +1558,7 @@ describe("USS Action Unit Tests - downloading functions", () => { const result = await (USSActions as any).getUssDownloadOptions(mockNode, true); expect(result.overwrite).toBe(true); - expect(result.includeHidden).toBe(true); + expect(result.dirOptions.includeHidden).toBe(true); expect(result.generateDirectory).toBe(true); }); @@ -1184,9 +1567,9 @@ describe("USS Action Unit Tests - downloading functions", () => { const storedOptions = { overwrite: true, generateDirectory: false, - includeHidden: true, chooseEncoding: false, selectedPath: vscode.Uri.file("/stored/path"), + dirOptions: { includeHidden: true }, }; mockZoweLocalStorage.mockReturnValue(storedOptions); @@ -1196,6 +1579,7 @@ describe("USS Action Unit Tests - downloading functions", () => { }); mockShowOpenDialog.mockResolvedValue([vscode.Uri.file("/new/path")]); + jest.spyOn(SharedUtils, "promptForDirectoryEncoding").mockResolvedValue({ kind: "other", codepage: "IBM-1047" }); const result = await (USSActions as any).getUssDownloadOptions(mockNode, true); @@ -1280,9 +1664,15 @@ describe("USS Action Unit Tests - downloading functions", () => { expect(result).toEqual({ overwrite: false, generateDirectory: false, - includeHidden: false, chooseEncoding: false, selectedPath: vscode.Uri.file("/test/path"), + dirOptions: { + includeHidden: false, + filesys: false, + symlinks: false, + chooseFilterOptions: false, + }, + dirFilterOptions: {}, }); }); @@ -1310,6 +1700,7 @@ describe("USS Action Unit Tests - downloading functions", () => { mockZoweLocalStorage.mockReturnValue({}); const mockUssApi = { getTag: jest.fn().mockResolvedValue("utf-8") } as any; jest.spyOn(ZoweExplorerApiRegister, "getUssApi").mockReturnValue(mockUssApi); + jest.spyOn(SharedUtils, "promptForDirectoryEncoding").mockResolvedValue(undefined); mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { mockQuickPick.selectedItems = [{ label: "Choose Encoding", picked: true }]; @@ -1321,7 +1712,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await (USSActions as any).getUssDownloadOptions(mockNode, true); expect(mockUssApi.getTag).not.toHaveBeenCalled(); - expect(SharedUtils.promptForEncoding).toHaveBeenCalledWith(mockNode, undefined); + expect(SharedUtils.promptForDirectoryEncoding).toHaveBeenCalledWith(mockNode.getProfile(), mockNode.fullPath, undefined); }); }); @@ -1351,7 +1742,7 @@ describe("USS Action Unit Tests - downloading functions", () => { encoding: undefined, }) ); - expect(globalMocks.showMessage).toHaveBeenCalledWith("File downloaded successfully"); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS file"); }); it("should download a USS file with binary encoding", async () => { @@ -1376,6 +1767,7 @@ describe("USS Action Unit Tests - downloading functions", () => { binary: true, }) ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS file"); }); it("should download a USS file with custom codepage encoding", async () => { @@ -1401,6 +1793,7 @@ describe("USS Action Unit Tests - downloading functions", () => { encoding: "IBM-1047", }) ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS file"); }); it("should download a USS file with directory structure generation", async () => { @@ -1425,6 +1818,7 @@ describe("USS Action Unit Tests - downloading functions", () => { file: expect.stringMatching(/u.test.file\.txt$/), }) ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS file"); }); it("should show cancellation message when download options are cancelled", async () => { @@ -1471,7 +1865,10 @@ describe("USS Action Unit Tests - downloading functions", () => { selectedPath: vscode.Uri.file("/test/download/path"), generateDirectory: false, overwrite: true, - includeHidden: false, + dirOptions: { + includeHidden: false, + directoryEncoding: { kind: "other", codepage: "IBM-1047" }, + }, encoding: { kind: "other", codepage: "IBM-1047" }, }; @@ -1485,7 +1882,7 @@ describe("USS Action Unit Tests - downloading functions", () => { await USSActions.downloadUssDirectory(mockNode); expect(ZoweLogger.trace).toHaveBeenCalledWith("uss.actions.downloadUssDirectory called."); - expect(USSUtils.countAllFilesRecursively).toHaveBeenCalledWith(mockNode); + expect(USSUtils.countAllFilesRecursively).toHaveBeenCalledWith(mockNode, undefined); expect(ZoweExplorerApiRegister.getUssApi).toHaveBeenCalledWith(mockNode.getProfile()); expect(globalMocks.ussApi.downloadDirectory).toHaveBeenCalledWith( "/u/test/directory", @@ -1498,7 +1895,7 @@ describe("USS Action Unit Tests - downloading functions", () => { maxConcurrentRequests: 1, }) ); - expect(globalMocks.showMessage).toHaveBeenCalledWith("Directory downloaded successfully"); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS directory"); }); it("should download a USS directory with directory structure generation", async () => { @@ -1508,7 +1905,7 @@ describe("USS Action Unit Tests - downloading functions", () => { selectedPath: vscode.Uri.file("/test/download/path"), generateDirectory: true, overwrite: false, - includeHidden: true, + dirOptions: { includeHidden: true }, encoding: { kind: "binary" }, }; @@ -1526,10 +1923,10 @@ describe("USS Action Unit Tests - downloading functions", () => { expect.objectContaining({ directory: expect.stringMatching(/u.test.directory$/), overwrite: false, - binary: true, includeHidden: true, }) ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS directory"); }); it("should show info message when directory contains no files", async () => { @@ -1557,7 +1954,7 @@ describe("USS Action Unit Tests - downloading functions", () => { selectedPath: vscode.Uri.file("/test/download/path"), generateDirectory: false, overwrite: false, - includeHidden: false, + dirOptions: { includeHidden: false }, encoding: undefined, }; @@ -1581,6 +1978,7 @@ describe("USS Action Unit Tests - downloading functions", () => { }) ); expect(globalMocks.ussApi.downloadDirectory).toHaveBeenCalled(); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS directory"); }); it("should cancel download when user chooses No for large directory", async () => { @@ -1589,7 +1987,7 @@ describe("USS Action Unit Tests - downloading functions", () => { selectedPath: vscode.Uri.file("/test/download/path"), generateDirectory: false, overwrite: false, - includeHidden: false, + dirOptions: { includeHidden: false }, encoding: undefined, }; @@ -1609,7 +2007,7 @@ describe("USS Action Unit Tests - downloading functions", () => { selectedPath: vscode.Uri.file("/test/download/path"), generateDirectory: false, overwrite: false, - includeHidden: false, + dirOptions: { includeHidden: false }, encoding: undefined, }; @@ -1642,7 +2040,7 @@ describe("USS Action Unit Tests - downloading functions", () => { selectedPath: vscode.Uri.file("/test/download/path"), generateDirectory: false, overwrite: false, - includeHidden: false, + dirOptions: { includeHidden: false }, encoding: undefined, }; @@ -1678,7 +2076,7 @@ describe("USS Action Unit Tests - downloading functions", () => { selectedPath: vscode.Uri.file("/test/download/path"), generateDirectory: false, overwrite: false, - includeHidden: false, + dirOptions: { includeHidden: false }, encoding: undefined, }; @@ -1694,11 +2092,11 @@ describe("USS Action Unit Tests - downloading functions", () => { expect(globalMocks.ussApi.downloadDirectory).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - encoding: "utf-8", maxConcurrentRequests: 5, responseTimeout: 30000, }) ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS directory"); }); }); }); diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts index 7245b624fd..4ea3012544 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts @@ -18,6 +18,7 @@ import { ZoweExplorerApiRegister } from "../../../../src/extending/ZoweExplorerA import { ZoweLogger } from "../../../../src/tools/ZoweLogger"; import { SharedContext } from "../../../../src/trees/shared/SharedContext"; import { MockedProperty } from "../../../__mocks__/mockUtils"; +import { Constants } from "../../../../src/configuration/Constants"; jest.mock("../../../../src/tools/ZoweLogger"); jest.mock("fs"); @@ -435,4 +436,62 @@ describe("USSUtils Unit Tests - countAllFilesRecursively", () => { expect(result).toBe(2); expect(ZoweLogger.warn).toHaveBeenCalled(); }); + + it("should return early when file count exceeds MIN_WARN_DOWNLOAD_FILES constant", async () => { + const mockConstant = 3; + const constantMock = new MockedProperty(Constants, "MIN_WARN_DOWNLOAD_FILES", undefined, mockConstant); + + const mockFile1 = { fullPath: "/test/file1.txt" } as IZoweUSSTreeNode; + const mockFile2 = { fullPath: "/test/file2.txt" } as IZoweUSSTreeNode; + const mockFile3 = { fullPath: "/test/file3.txt" } as IZoweUSSTreeNode; + const mockFile4 = { fullPath: "/test/file4.txt" } as IZoweUSSTreeNode; + const mockFile5 = { fullPath: "/test/file5.txt" } as IZoweUSSTreeNode; + + const mockSubDir = { + getChildren: jest.fn().mockResolvedValue([mockFile4, mockFile5]), + fullPath: "/test/subdir", + } as unknown as IZoweUSSTreeNode; + + const mockNode = { + getChildren: jest.fn().mockResolvedValue([mockFile1, mockFile2, mockFile3, mockSubDir]), + fullPath: "/test", + } as unknown as IZoweUSSTreeNode; + + globalMocks.isUssDirectory.mockImplementation((node: any) => node.fullPath === "/test/subdir"); + + const result = await USSUtils.countAllFilesRecursively(mockNode); + + expect(result).toBeGreaterThan(mockConstant); + expect(mockSubDir.getChildren).not.toHaveBeenCalled(); + + constantMock[Symbol.dispose](); + }); + + it("should stop recursion at specified depth", async () => { + const mockFile1 = { fullPath: "/test/file1.txt" } as IZoweUSSTreeNode; + const mockDeepFile = { fullPath: "/test/sub1/sub2/deepfile.txt" } as IZoweUSSTreeNode; + + const mockSub2 = { + getChildren: jest.fn().mockResolvedValue([mockDeepFile]), + fullPath: "/test/sub1/sub2", + } as unknown as IZoweUSSTreeNode; + + const mockSub1 = { + getChildren: jest.fn().mockResolvedValue([mockSub2]), + fullPath: "/test/sub1", + } as unknown as IZoweUSSTreeNode; + + const mockNode = { + getChildren: jest.fn().mockResolvedValue([mockFile1, mockSub1]), + fullPath: "/test", + } as unknown as IZoweUSSTreeNode; + + globalMocks.isUssDirectory.mockImplementation((node: any) => node.fullPath === "/test/sub1" || node.fullPath === "/test/sub1/sub2"); + + const result = await USSUtils.countAllFilesRecursively(mockNode, 2); + + expect(result).toBe(1); + expect(mockSub1.getChildren).toHaveBeenCalled(); + expect(mockSub2.getChildren).not.toHaveBeenCalled(); + }); }); diff --git a/packages/zowe-explorer/src/trees/uss/USSActions.ts b/packages/zowe-explorer/src/trees/uss/USSActions.ts index 3d7124f98f..593feee926 100644 --- a/packages/zowe-explorer/src/trees/uss/USSActions.ts +++ b/packages/zowe-explorer/src/trees/uss/USSActions.ts @@ -430,7 +430,7 @@ export class USSActions { break; case "user": prompt = vscode.l10n.t("Enter user name or UID"); - placeholder = vscode.l10n.t("e.g., john or 1001"); + placeholder = vscode.l10n.t("e.g., IBMUSER or 1001"); break; case "mtime": prompt = vscode.l10n.t("Enter modification time filter"); @@ -610,19 +610,29 @@ export class USSActions { return; } - const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); - downloadOpts.overwrite = getOption("Overwrite"); - downloadOpts.generateDirectory = getOption("Generate Directory Structure"); - downloadOpts.chooseEncoding = getOption("Choose Encoding"); + const localizedLabels = { + overwrite: vscode.l10n.t("Overwrite"), + generateDirectory: vscode.l10n.t("Generate Directory Structure"), + chooseEncoding: vscode.l10n.t("Choose Encoding"), + includeHidden: vscode.l10n.t("Include Hidden Files"), + searchAllFilesystems: vscode.l10n.t("Search All Filesystems"), + returnSymlinks: vscode.l10n.t("Return Symlinks"), + setFilterOptions: vscode.l10n.t("Set Filter Options"), + }; + + const getOption = (localizedLabel: string): boolean => selectedOptions.some((opt) => opt.label === localizedLabel); + downloadOpts.overwrite = getOption(localizedLabels.overwrite); + downloadOpts.generateDirectory = getOption(localizedLabels.generateDirectory); + downloadOpts.chooseEncoding = getOption(localizedLabels.chooseEncoding); // Only set directory-specific options when downloading directories if (isDirectory) { - downloadOpts.dirOptions.includeHidden = getOption("Include Hidden Files"); - downloadOpts.dirOptions.filesys = getOption("Search All Filesystems"); - downloadOpts.dirOptions.symlinks = getOption("Return Symlinks"); - downloadOpts.dirOptions.chooseFilterOptions = getOption("Set Filter Options"); + downloadOpts.dirOptions.includeHidden = getOption(localizedLabels.includeHidden); + downloadOpts.dirOptions.filesys = getOption(localizedLabels.searchAllFilesystems); + downloadOpts.dirOptions.symlinks = getOption(localizedLabels.returnSymlinks); + downloadOpts.dirOptions.chooseFilterOptions = getOption(localizedLabels.setFilterOptions); - if (getOption("Set Filter Options")) { + if (getOption(localizedLabels.setFilterOptions)) { const filterOptions = await USSActions.getUssDirFilterOptions(downloadOpts.dirFilterOptions); if (filterOptions === null) { return; diff --git a/packages/zowe-explorer/src/trees/uss/USSUtils.ts b/packages/zowe-explorer/src/trees/uss/USSUtils.ts index b98f42ae3a..09de2b7e2b 100644 --- a/packages/zowe-explorer/src/trees/uss/USSUtils.ts +++ b/packages/zowe-explorer/src/trees/uss/USSUtils.ts @@ -122,15 +122,10 @@ export class USSUtils { ZoweLogger.trace("uss.actions.countAllFilesRecursively called."); let totalCount = 0; - // Return early to avoid unnecessary recursion - if (totalCount > Constants.MIN_WARN_DOWNLOAD_FILES) { - return totalCount; - } - try { // Force the node to refresh its children, because after downloading a node, // the tree seems to uncollapse at that node and is not marked as dirty - (node as any).dirty = true; + node.dirty = true; const children = await node.getChildren(); if (!children || children.length === 0) { @@ -145,6 +140,11 @@ export class USSUtils { } else { totalCount += 1; } + + // Return early to avoid unnecessary recursion + if (totalCount > Constants.MIN_WARN_DOWNLOAD_FILES) { + return totalCount; + } } } catch (error) { ZoweLogger.warn(`Failed to count files in directory ${node.fullPath}: ${String(error)}`); From a44a6ba2d21976c2317ac2ac8e957cd1bf568f8a Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Mon, 20 Oct 2025 17:36:59 +0100 Subject: [PATCH 28/29] refactor: change context menu groupings Signed-off-by: JWaters02 --- packages/zowe-explorer/package.json | 130 ++++++++++++++-------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/packages/zowe-explorer/package.json b/packages/zowe-explorer/package.json index 29aff39ccf..d086b9056a 100644 --- a/packages/zowe-explorer/package.json +++ b/packages/zowe-explorer/package.json @@ -934,30 +934,15 @@ "command": "zowe.uss.createFile", "group": "001_zowe_ussCreate@2" }, - { - "when": "view == zowe.uss.explorer && viewItem =~ /^directory.*/ && !listMultiSelection", - "command": "zowe.uss.uploadDialog", - "group": "001_zowe_ussCreate@3" - }, - { - "when": "view == zowe.uss.explorer && viewItem =~ /^directory.*/ && !listMultiSelection", - "command": "zowe.uss.uploadDialogBinary", - "group": "001_zowe_ussCreate@4" - }, - { - "when": "view == zowe.uss.explorer && viewItem =~ /^directory.*/ && !listMultiSelection", - "command": "zowe.uss.uploadDialogWithEncoding", - "group": "001_zowe_ussCreate@5" - }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!.*_fav.*)(textFile.*|binaryFile.*|directory.*)/", "command": "zowe.uss.copyUssFile", - "group": "001_zowe_ussCreate@6" + "group": "001_zowe_ussCreate@7" }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!.*_fav.*)(textFile.*|binaryFile.*|directory.*|ussSession.*_isFilterSearch)/", "command": "zowe.uss.pasteUssFile", - "group": "001_zowe_ussCreate@7" + "group": "001_zowe_ussCreate@8" }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!(ussSession|favorite|profile_fav))/ && !listMultiSelection", @@ -974,35 +959,60 @@ "command": "zowe.copyExternalLink", "group": "002_zowe_ussSystemSpecific@6" }, + { + "when": "view == zowe.uss.explorer && viewItem =~ /^directory.*/ && !listMultiSelection", + "command": "zowe.uss.uploadDialog", + "group": "003_zowe_ussTransfer@1" + }, + { + "when": "view == zowe.uss.explorer && viewItem =~ /^directory.*/ && !listMultiSelection", + "command": "zowe.uss.uploadDialogBinary", + "group": "003_zowe_ussTransfer@2" + }, + { + "when": "view == zowe.uss.explorer && viewItem =~ /^directory.*/ && !listMultiSelection", + "command": "zowe.uss.uploadDialogWithEncoding", + "group": "003_zowe_ussTransfer@3" + }, + { + "when": "view == zowe.uss.explorer && viewItem =~ /^textFile|binaryFile/ && !listMultiSelection", + "command": "zowe.uss.downloadFile", + "group": "003_zowe_ussTransfer@6" + }, + { + "when": "view == zowe.uss.explorer && viewItem =~ /^directory.*/ && !listMultiSelection", + "command": "zowe.uss.downloadDirectory", + "group": "003_zowe_ussTransfer@7" + }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!.*_fav.*)(textFile.*|binaryFile.*|directory.*)/", "command": "zowe.addFavorite", - "group": "003_zowe_ussWorkspace@0" + "group": "004_zowe_ussWorkspace@0" }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!(ussSession.*|profile.*)).*_fav.*/", "command": "zowe.removeFavorite", - "group": "003_zowe_ussWorkspace@1" + "group": "004_zowe_ussWorkspace@1" }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(directory.*|ussSession.*)/", "command": "zowe.addToWorkspace", - "group": "003_zowe_ussWorkspace@2" + "group": "004_zowe_ussWorkspace@2" }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!.*_fav.*)ussSession.*/ && !listMultiSelection", "command": "zowe.addFavorite", - "group": "003_zowe_ussWorkspace@3" + "group": "004_zowe_ussWorkspace@3" }, { "when": "view == zowe.uss.explorer && viewItem =~ /^ussSession.*_fav.*/ && !listMultiSelection", "command": "zowe.removeFavorite", - "group": "003_zowe_ussWorkspace@4" + "group": "004_zowe_ussWorkspace@4" }, { "when": "view == zowe.uss.explorer && viewItem == profile_fav && !listMultiSelection", "command": "zowe.removeFavProfile", - "group": "003_zowe_ussWorkspace@5" + "group": "004_zowe_ussWorkspace@5" }, { "when": "view == zowe.uss.explorer && viewItem =~ /^textFile|binaryFile/ && !listMultiSelection", @@ -1019,16 +1029,6 @@ "command": "zowe.uss.renameNode", "group": "099_zowe_ussModification:@3" }, - { - "when": "view == zowe.uss.explorer && viewItem =~ /^textFile|binaryFile/ && !listMultiSelection", - "command": "zowe.uss.downloadFile", - "group": "099_zowe_ussModification:@5" - }, - { - "when": "view == zowe.uss.explorer && viewItem =~ /^directory.*/ && !listMultiSelection", - "command": "zowe.uss.downloadDirectory", - "group": "099_zowe_ussModification:@5" - }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!(ussSession|favorite|.*_fav))/", "command": "zowe.uss.deleteNode", @@ -1144,16 +1144,6 @@ "command": "zowe.ds.createMember", "group": "001_zowe_dsCreate@4" }, - { - "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/ && !listMultiSelection", - "command": "zowe.ds.uploadDialog", - "group": "001_zowe_dsCreate@5" - }, - { - "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/ && !listMultiSelection", - "command": "zowe.ds.uploadDialogWithEncoding", - "group": "001_zowe_dsCreate@6" - }, { "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/ && !listMultiSelection", "command": "zowe.ds.pdsSearchFor", @@ -1169,40 +1159,65 @@ "command": "zowe.ds.pasteDataSets", "group": "001_zowe_dsCreate@9" }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/ && !listMultiSelection", + "command": "zowe.ds.uploadDialog", + "group": "002_zowe_dsTransfer@1" + }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/ && !listMultiSelection", + "command": "zowe.ds.uploadDialogWithEncoding", + "group": "002_zowe_dsTransfer@2" + }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^ds.*/ && !listMultiSelection", + "command": "zowe.ds.downloadDataSet", + "group": "002_zowe_dsTransfer@5" + }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/ && !listMultiSelection", + "command": "zowe.ds.downloadAllMembers", + "group": "002_zowe_dsTransfer@6" + }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^member.*/ && !listMultiSelection", + "command": "zowe.ds.downloadMember", + "group": "002_zowe_dsTransfer@7" + }, { "when": "view == zowe.ds.explorer && viewItem =~ /^(?!.*_fav.*)session.*/ && !listMultiSelection", "command": "zowe.saveSearch", - "group": "002_zowe_dsWorkspace@0" + "group": "003_zowe_dsWorkspace@0" }, { "when": "view == zowe.ds.explorer && viewItem =~ /^(?!.*_fav|.*isFilterSearch)(member|migr|ds|pds).*/", "command": "zowe.addFavorite", - "group": "002_zowe_dsWorkspace@0" + "group": "003_zowe_dsWorkspace@0" }, { "when": "view == zowe.ds.explorer && viewItem =~ /^session.*_fav.*/ && !listMultiSelection", "command": "zowe.removeFavorite", - "group": "002_zowe_dsWorkspace@1" + "group": "003_zowe_dsWorkspace@1" }, { "when": "view == zowe.ds.explorer && viewItem =~ /^(pds|ds|migr).*_fav.*/", "command": "zowe.removeFavorite", - "group": "002_zowe_dsWorkspace@1" + "group": "003_zowe_dsWorkspace@1" }, { "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/", "command": "zowe.addToWorkspace", - "group": "002_zowe_dsWorkspace@2" + "group": "003_zowe_dsWorkspace@2" }, { "when": "view == zowe.ds.explorer && viewItem =~ /^session.*_fav.*/ && !listMultiSelection", "command": "zowe.ds.filteredDataSetsSearchFor", - "group": "002_zowe_dsWorkspace@3" + "group": "003_zowe_dsWorkspace@3" }, { "when": "view == zowe.ds.explorer && viewItem == profile_fav && !listMultiSelection", "command": "zowe.removeFavProfile", - "group": "002_zowe_dsWorkspace@4" + "group": "003_zowe_dsWorkspace@4" }, { "when": "view == zowe.ds.explorer && viewItem =~ /^(pds|ds|migr|member|vsam).*/ && !listMultiSelection", @@ -1264,21 +1279,6 @@ "command": "zowe.ds.renameDataSet", "group": "099_zowe_dsModification@2" }, - { - "when": "view == zowe.ds.explorer && viewItem =~ /^ds.*/ && !listMultiSelection", - "command": "zowe.ds.downloadDataSet", - "group": "099_zowe_dsModification@3" - }, - { - "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/ && !listMultiSelection", - "command": "zowe.ds.downloadAllMembers", - "group": "099_zowe_dsModification@3" - }, - { - "when": "view == zowe.ds.explorer && viewItem =~ /^member.*/ && !listMultiSelection", - "command": "zowe.ds.downloadMember", - "group": "099_zowe_dsModification@3" - }, { "when": "view == zowe.ds.explorer && viewItem =~ /^ds.*/", "command": "zowe.ds.deleteDataset", From 1b06aadf963365b4faacc3aaa9dcd337ea06d00e Mon Sep 17 00:00:00 2001 From: JWaters02 Date: Mon, 20 Oct 2025 17:37:13 +0100 Subject: [PATCH 29/29] chore: prepublish Signed-off-by: JWaters02 --- packages/zowe-explorer/l10n/bundle.l10n.json | 97 +++++++++++++++++ packages/zowe-explorer/l10n/poeditor.json | 107 +++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/packages/zowe-explorer/l10n/bundle.l10n.json b/packages/zowe-explorer/l10n/bundle.l10n.json index fb59d8e568..dbde01ab8f 100644 --- a/packages/zowe-explorer/l10n/bundle.l10n.json +++ b/packages/zowe-explorer/l10n/bundle.l10n.json @@ -312,6 +312,7 @@ "File path" ] }, + "Cannot fetch encoding for directories. Encoding options are only available for files.": "Cannot fetch encoding for directories. Encoding options are only available for files.", "Profile does not exist for this file.": "Profile does not exist for this file.", "Saving USS file...": "Saving USS file...", "Failed to rename {0}/File path": { @@ -361,6 +362,72 @@ "Uploading file...": "Uploading file...", "Uploading USS file": "Uploading USS file", "Uploading USS file with encoding": "Uploading USS file with encoding", + "Filter by group owner or GID (current: {0})": "Filter by group owner or GID (current: {0})", + "Filter by group owner or GID": "Filter by group owner or GID", + "Filter by user name or UID (current: {0})": "Filter by user name or UID (current: {0})", + "Filter by user name or UID": "Filter by user name or UID", + "Modification Time": "Modification Time", + "Filter by modification time in days (current: {0})": "Filter by modification time in days (current: {0})", + "Filter by modification time in days (e.g., +7, -1, 30)": "Filter by modification time in days (e.g., +7, -1, 30)", + "Size": "Size", + "Filter by file size (current: {0})": "Filter by file size (current: {0})", + "Filter by file size (e.g., +1M, -500K, 100G)": "Filter by file size (e.g., +1M, -500K, 100G)", + "Permissions": "Permissions", + "Filter by permission octal mask (current: {0})": "Filter by permission octal mask (current: {0})", + "Filter by permission octal mask (e.g., 755, -644)": "Filter by permission octal mask (e.g., 755, -644)", + "File Type": "File Type", + "Filter by file type (current: {0})": "Filter by file type (current: {0})", + "Filter by file type (c=character, d=directory, f=file, l=symlink, p=pipe, s=socket)": "Filter by file type (c=character, d=directory, f=file, l=symlink, p=pipe, s=socket)", + "Depth": "Depth", + "Directory depth to search (current: {0})": "Directory depth to search (current: {0})", + "Directory depth to search (number of levels)": "Directory depth to search (number of levels)", + "Configure Filter Options": "Configure Filter Options", + "Select filters to configure": "Select filters to configure", + "Enter group owner or GID": "Enter group owner or GID", + "e.g., admin or 100": "e.g., admin or 100", + "Enter user name or UID": "Enter user name or UID", + "e.g., IBMUSER or 1001": "e.g., IBMUSER or 1001", + "Enter modification time filter": "Enter modification time filter", + "e.g., +7 (older than 7 days), -1 (newer than 1 day), 30 (exactly 30 days)": "e.g., +7 (older than 7 days), -1 (newer than 1 day), 30 (exactly 30 days)", + "Enter size filter": "Enter size filter", + "e.g., +1M (larger than 1MB), -500K (smaller than 500KB), 100G": "e.g., +1M (larger than 1MB), -500K (smaller than 500KB), 100G", + "Enter permission octal mask": "Enter permission octal mask", + "e.g., 755, -644 (not 644)": "e.g., 755, -644 (not 644)", + "Enter file type": "Enter file type", + "c, d, f, l, p, or s": "c, d, f, l, p, or s", + "Enter directory depth": "Enter directory depth", + "e.g., 2 (search 2 levels deep)": "e.g., 2 (search 2 levels deep)", + "Value cannot be empty": "Value cannot be empty", + "Must be a valid number": "Must be a valid number", + "Select default encoding for directory files (current: Auto-detect from file tags)": "Select default encoding for directory files (current: Auto-detect from file tags)", + "Select default encoding for directory files (current: {0})": "Select default encoding for directory files (current: {0})", + "Select specific encoding for file": "Select specific encoding for file", + "Select specific encoding for file (current: {0})": "Select specific encoding for file (current: {0})", + "Overwrite": "Overwrite", + "Overwrite existing files when downloading directories": "Overwrite existing files when downloading directories", + "Overwrite existing file": "Overwrite existing file", + "Generate Directory Structure": "Generate Directory Structure", + "Generates sub-folders based on the USS path": "Generates sub-folders based on the USS path", + "Include Hidden Files": "Include Hidden Files", + "Include hidden files when downloading directories": "Include hidden files when downloading directories", + "Search All Filesystems": "Search All Filesystems", + "Search all filesystems under the path (not just same filesystem)": "Search all filesystems under the path (not just same filesystem)", + "Return Symlinks": "Return Symlinks", + "Return symbolic links instead of following them": "Return symbolic links instead of following them", + "Set Filter Options": "Set Filter Options", + "Configure file filtering options (currently configured)": "Configure file filtering options (currently configured)", + "Configure file filtering options": "Configure file filtering options", + "Choose Encoding": "Choose Encoding", + "Download Options": "Download Options", + "Select download options": "Select download options", + "Select Download Location": "Select Download Location", + "Downloading USS file...": "Downloading USS file...", + "USS file": "USS file", + "The selected directory contains no files to download.": "The selected directory contains no files to download.", + "This directory has {0} members. Downloading a large number of files may take a long time. Do you want to continue?": "This directory has {0} members. Downloading a large number of files may take a long time. Do you want to continue?", + "Downloading USS directory": "Downloading USS directory", + "Download cancelled": "Download cancelled", + "USS directory": "USS directory", "Delete action was canceled.": "Delete action was canceled.", "Copying file structure...": "Copying file structure...", "The paste operation is not supported for this node.": "The paste operation is not supported for this node.", @@ -384,6 +451,8 @@ "Raw data representation": "Raw data representation", "Other": "Other", "Specify another codepage": "Specify another codepage", + "Auto-detect from file tags": "Auto-detect from file tags", + "Let the API infer encoding from individual USS file tags": "Let the API infer encoding from individual USS file tags", "From profile {0}/Profile name": { "message": "From profile {0}", "comment": [ @@ -422,6 +491,12 @@ "Encoding name" ] }, + "Choose encoding for files in {0}/Directory path": { + "message": "Choose encoding for files in {0}", + "comment": [ + "Directory path" + ] + }, "A search must be set for {0} before it can be added to a workspace./Name of USS session": { "message": "A search must be set for {0} before it can be added to a workspace.", "comment": [ @@ -431,6 +506,12 @@ "(default)": "(default)", "Confirm": "Confirm", "One or more items may be overwritten from this drop operation. Confirm or cancel?": "One or more items may be overwritten from this drop operation. Confirm or cancel?", + "{0} download completed.": "{0} download completed.", + "{0} download completed with errors.": "{0} download completed with errors.", + "{0} downloaded successfully.": "{0} downloaded successfully.", + "{0}\n\nSome files may not have been downloaded.": "{0}\n\nSome files may not have been downloaded.", + "View Details": "View Details", + "{0}\n\nSome files may have been skipped.": "{0}\n\nSome files may have been skipped.", "Team config file created, refreshing Zowe Explorer.": "Team config file created, refreshing Zowe Explorer.", "Team config file deleted, refreshing Zowe Explorer.": "Team config file deleted, refreshing Zowe Explorer.", "Team config file updated, refreshing Zowe Explorer.": "Team config file updated, refreshing Zowe Explorer.", @@ -943,6 +1024,22 @@ "Uploading to data set": "Uploading to data set", "This action is only supported for partitioned data sets.": "This action is only supported for partitioned data sets.", "Uploading to data set...": "Uploading to data set...", + "Overwrite existing files": "Overwrite existing files", + "Generates sub-folders based on the data set name": "Generates sub-folders based on the data set name", + "Preserve Original Letter Case": "Preserve Original Letter Case", + "Specifies if the automatically generated directories and files use the original letter case": "Specifies if the automatically generated directories and files use the original letter case", + "Download members as binary files": "Download members as binary files", + "Record": "Record", + "Download members in record mode": "Download members in record mode", + "The selected data set has no members to download.": "The selected data set has no members to download.", + "This data set has {0} members. Downloading a large number of files may take a long time. Do you want to continue?": "This data set has {0} members. Downloading a large number of files may take a long time. Do you want to continue?", + "Downloading all members": "Downloading all members", + "Data set members": "Data set members", + "Downloading member": "Downloading member", + "Data set member": "Data set member", + "Cannot download this type of data set.": "Cannot download this type of data set.", + "Downloading data set": "Downloading data set", + "Data set": "Data set", "No data sets selected for deletion, cancelling...": "No data sets selected for deletion, cancelling...", "Deleting data set(s): {0}/Data Sets to delete": { "message": "Deleting data set(s): {0}", diff --git a/packages/zowe-explorer/l10n/poeditor.json b/packages/zowe-explorer/l10n/poeditor.json index 5afcd7139c..c12506ace1 100644 --- a/packages/zowe-explorer/l10n/poeditor.json +++ b/packages/zowe-explorer/l10n/poeditor.json @@ -191,6 +191,12 @@ "uss.uploadDialogWithEncoding": { "Upload Files with Encoding...": "" }, + "uss.downloadFile": { + "Download File...": "" + }, + "uss.downloadDirectory": { + "Download Directory...": "" + }, "uss.text": { "Toggle Text": "" }, @@ -515,6 +521,15 @@ "ds.sortBy": { "Sort PDS Members...": "" }, + "downloadDataSet": { + "Download Data Set...": "" + }, + "downloadAllMembers": { + "Download All Members...": "" + }, + "downloadMember": { + "Download Member...": "" + }, "issueUnixCmd": { "Issue Unix Command": "" }, @@ -734,6 +749,7 @@ "Pulling from Mainframe...": "", "The 'move' function is not implemented for this USS API.": "", "Failed to move {0}": "", + "Cannot fetch encoding for directories. Encoding options are only available for files.": "", "Profile does not exist for this file.": "", "Saving USS file...": "", "Failed to rename {0}": "", @@ -751,6 +767,72 @@ "Uploading file...": "", "Uploading USS file": "", "Uploading USS file with encoding": "", + "Filter by group owner or GID (current: {0})": "", + "Filter by group owner or GID": "", + "Filter by user name or UID (current: {0})": "", + "Filter by user name or UID": "", + "Modification Time": "", + "Filter by modification time in days (current: {0})": "", + "Filter by modification time in days (e.g., +7, -1, 30)": "", + "Size": "", + "Filter by file size (current: {0})": "", + "Filter by file size (e.g., +1M, -500K, 100G)": "", + "Permissions": "", + "Filter by permission octal mask (current: {0})": "", + "Filter by permission octal mask (e.g., 755, -644)": "", + "File Type": "", + "Filter by file type (current: {0})": "", + "Filter by file type (c=character, d=directory, f=file, l=symlink, p=pipe, s=socket)": "", + "Depth": "", + "Directory depth to search (current: {0})": "", + "Directory depth to search (number of levels)": "", + "Configure Filter Options": "", + "Select filters to configure": "", + "Enter group owner or GID": "", + "e.g., admin or 100": "", + "Enter user name or UID": "", + "e.g., IBMUSER or 1001": "", + "Enter modification time filter": "", + "e.g., +7 (older than 7 days), -1 (newer than 1 day), 30 (exactly 30 days)": "", + "Enter size filter": "", + "e.g., +1M (larger than 1MB), -500K (smaller than 500KB), 100G": "", + "Enter permission octal mask": "", + "e.g., 755, -644 (not 644)": "", + "Enter file type": "", + "c, d, f, l, p, or s": "", + "Enter directory depth": "", + "e.g., 2 (search 2 levels deep)": "", + "Value cannot be empty": "", + "Must be a valid number": "", + "Select default encoding for directory files (current: Auto-detect from file tags)": "", + "Select default encoding for directory files (current: {0})": "", + "Select specific encoding for file": "", + "Select specific encoding for file (current: {0})": "", + "Overwrite": "", + "Overwrite existing files when downloading directories": "", + "Overwrite existing file": "", + "Generate Directory Structure": "", + "Generates sub-folders based on the USS path": "", + "Include Hidden Files": "", + "Include hidden files when downloading directories": "", + "Search All Filesystems": "", + "Search all filesystems under the path (not just same filesystem)": "", + "Return Symlinks": "", + "Return symbolic links instead of following them": "", + "Set Filter Options": "", + "Configure file filtering options (currently configured)": "", + "Configure file filtering options": "", + "Choose Encoding": "", + "Download Options": "", + "Select download options": "", + "Select Download Location": "", + "Downloading USS file...": "", + "USS file": "", + "The selected directory contains no files to download.": "", + "This directory has {0} members. Downloading a large number of files may take a long time. Do you want to continue?": "", + "Downloading USS directory": "", + "Download cancelled": "", + "USS directory": "", "Delete action was canceled.": "", "Copying file structure...": "", "The paste operation is not supported for this node.": "", @@ -764,6 +846,8 @@ "Raw data representation": "", "Other": "", "Specify another codepage": "", + "Auto-detect from file tags": "", + "Let the API infer encoding from individual USS file tags": "", "From profile {0}": "", "USS file tag": "", "Choose encoding for {0}": "", @@ -771,10 +855,17 @@ "Choose encoding for upload to {0}": "", "Default encoding is {0}": "", "Current encoding is {0}": "", + "Choose encoding for files in {0}": "", "A search must be set for {0} before it can be added to a workspace.": "", "(default)": "", "Confirm": "", "One or more items may be overwritten from this drop operation. Confirm or cancel?": "", + "{0} download completed.": "", + "{0} download completed with errors.": "", + "{0} downloaded successfully.": "", + "{0}\n\nSome files may not have been downloaded.": "", + "View Details": "", + "{0}\n\nSome files may have been skipped.": "", "Team config file created, refreshing Zowe Explorer.": "", "Team config file deleted, refreshing Zowe Explorer.": "", "Team config file updated, refreshing Zowe Explorer.": "", @@ -1005,6 +1096,22 @@ "Uploading to data set": "", "This action is only supported for partitioned data sets.": "", "Uploading to data set...": "", + "Overwrite existing files": "", + "Generates sub-folders based on the data set name": "", + "Preserve Original Letter Case": "", + "Specifies if the automatically generated directories and files use the original letter case": "", + "Download members as binary files": "", + "Record": "", + "Download members in record mode": "", + "The selected data set has no members to download.": "", + "This data set has {0} members. Downloading a large number of files may take a long time. Do you want to continue?": "", + "Downloading all members": "", + "Data set members": "", + "Downloading member": "", + "Data set member": "", + "Cannot download this type of data set.": "", + "Downloading data set": "", + "Data set": "", "No data sets selected for deletion, cancelling...": "", "Deleting data set(s): {0}": "", "Deleting items": "",