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"), diff --git a/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts b/packages/zowe-explorer-api/src/extend/MainframeInteraction.ts index c0e00ebba4..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 @@ -235,6 +250,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 c671ef1b60..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" }); } @@ -260,6 +272,13 @@ import { IDataSetCount } from "../dataset/IDataSetCount"; }); } + 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-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."); } 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__/__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__/extension.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts index 1d5ed3630a..5568e67162 100644 --- a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts @@ -185,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", @@ -201,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", 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 af75590cab..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 @@ -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, @@ -3532,3 +3576,786 @@ describe("Dataset Actions Unit Tests - upload with encoding", () => { getTreeViewMock.mockRestore(); }); }); + +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, _downloadType, _node) => { + const response = await downloadFn(); + return response; + }) + ); + + 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()); + new MockedProperty(SharedUtils, "handleDownloadResponse", undefined, jest.fn().mockResolvedValue(undefined)); + }); + + 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), + "Data set members", + 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), + "Data set member", + 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", 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/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetInit.unit.test.ts index 210a68288d..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 @@ -120,6 +120,18 @@ describe("Test src/dataset/extension", () => { name: "zowe.ds.uploadDialogWithEncoding", mock: [{ spy: jest.spyOn(DatasetActions, "uploadDialogWithEncoding"), arg: [test.value, dsProvider] }], }, + { + 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/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 3a45d7025e..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 @@ -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 }); @@ -531,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 () => { @@ -1056,3 +1063,1040 @@ 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(SharedUtils, "handleDownloadResponse").mockResolvedValue(); + + 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(); + }); + + 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 () => { + 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, + 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({ + 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.dirOptions.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, + chooseEncoding: false, + selectedPath: vscode.Uri.file("/stored/path"), + dirOptions: { includeHidden: true }, + }; + mockZoweLocalStorage.mockReturnValue(storedOptions); + + mockQuickPick.onDidAccept.mockImplementation((callback: () => void) => { + mockQuickPick.selectedItems = [{ label: "Choose Encoding", picked: true }]; + callback(); + }); + + 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); + + 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, + chooseEncoding: false, + selectedPath: vscode.Uri.file("/test/path"), + dirOptions: { + includeHidden: false, + filesys: false, + symlinks: false, + chooseFilterOptions: false, + }, + dirFilterOptions: {}, + }); + }); + + 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); + jest.spyOn(SharedUtils, "promptForDirectoryEncoding").mockResolvedValue(undefined); + + 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.promptForDirectoryEncoding).toHaveBeenCalledWith(mockNode.getProfile(), mockNode.fullPath, 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.ussApi.getContents).toHaveBeenCalledWith( + "/u/test/file.txt", + expect.objectContaining({ + file: expect.stringContaining("file.txt"), + binary: false, + encoding: undefined, + }) + ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS file"); + }); + + 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.ussApi.getContents).toHaveBeenCalledWith( + "/u/test/file.txt", + expect.objectContaining({ + binary: true, + }) + ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS file"); + }); + + 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.ussApi.getContents).toHaveBeenCalledWith( + "/u/test/file.txt", + expect.objectContaining({ + binary: false, + encoding: "IBM-1047", + }) + ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS file"); + }); + + 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.ussApi.getContents).toHaveBeenCalledWith( + "/u/test/file.txt", + expect.objectContaining({ + 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 () => { + const mockNode = createMockNode(); + jest.spyOn(USSActions as any, "getUssDownloadOptions").mockResolvedValue(undefined); + + await USSActions.downloadUssFile(mockNode); + + expect(globalMocks.showMessage).toHaveBeenCalledWith("Operation cancelled"); + expect(globalMocks.ussApi.getContents).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.ussApi.getContents.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, + dirOptions: { + includeHidden: false, + directoryEncoding: { kind: "other", codepage: "IBM-1047" }, + }, + 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, undefined); + expect(ZoweExplorerApiRegister.getUssApi).toHaveBeenCalledWith(mockNode.getProfile()); + expect(globalMocks.ussApi.downloadDirectory).toHaveBeenCalledWith( + "/u/test/directory", + expect.objectContaining({ + directory: "/test/download/path", + overwrite: true, + binary: false, + encoding: "IBM-1047", + includeHidden: false, + maxConcurrentRequests: 1, + }) + ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS directory"); + }); + + 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, + dirOptions: { 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.ussApi.downloadDirectory).toHaveBeenCalledWith( + "/u/test/directory", + expect.objectContaining({ + directory: expect.stringMatching(/u.test.directory$/), + overwrite: false, + includeHidden: true, + }) + ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS directory"); + }); + + 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.ussApi.downloadDirectory).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, + dirOptions: { 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.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 () => { + const mockNode = createMockNode(); + const mockDownloadOptions = { + selectedPath: vscode.Uri.file("/test/download/path"), + generateDirectory: false, + overwrite: false, + dirOptions: { 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.ussApi.downloadDirectory).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, + dirOptions: { 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.ussApi.downloadDirectory).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.ussApi.downloadDirectory).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, + dirOptions: { 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.ussApi.downloadDirectory.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, + dirOptions: { 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.ussApi.downloadDirectory).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + maxConcurrentRequests: 5, + responseTimeout: 30000, + }) + ); + expect(SharedUtils.handleDownloadResponse).toHaveBeenCalledWith({ success: true, commandResponse: "", apiResponse: {} }, "USS directory"); + }); + }); +}); 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] }], 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..4ea3012544 --- /dev/null +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSUtils.unit.test.ts @@ -0,0 +1,497 @@ +/** + * 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 } from "@zowe/zowe-explorer-api"; +import { USSUtils } from "../../../../src/trees/uss/USSUtils"; +import { ZoweExplorerApiRegister } from "../../../../src/extending/ZoweExplorerApiRegister"; +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"); + +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(); + }); + + 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/l10n/bundle.l10n.json b/packages/zowe-explorer/l10n/bundle.l10n.json index f986d1e6ad..dbde01ab8f 100644 --- a/packages/zowe-explorer/l10n/bundle.l10n.json +++ b/packages/zowe-explorer/l10n/bundle.l10n.json @@ -243,42 +243,6 @@ "Uploading USS files...": "Uploading USS files...", "Error uploading files": "Error uploading files", "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": [ @@ -341,6 +305,43 @@ "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" + ] + }, + "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": { + "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": [ @@ -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 234341f264..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": "" }, @@ -706,15 +721,6 @@ "Uploading USS files...": "", "Error uploading files": "", "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": "", @@ -741,6 +747,16 @@ "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}": "", + "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}": "", + "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": "", @@ -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": "", diff --git a/packages/zowe-explorer/package.json b/packages/zowe-explorer/package.json index 662dd773bf..d086b9056a 100644 --- a/packages/zowe-explorer/package.json +++ b/packages/zowe-explorer/package.json @@ -356,6 +356,21 @@ "title": "%uploadDialogWithEncoding%", "category": "Zowe Explorer" }, + { + "command": "zowe.ds.downloadDataSet", + "title": "%downloadDataSet%", + "category": "Zowe Explorer" + }, + { + "command": "zowe.ds.downloadMember", + "title": "%downloadMember%", + "category": "Zowe Explorer" + }, + { + "command": "zowe.ds.downloadAllMembers", + "title": "%downloadAllMembers%", + "category": "Zowe Explorer" + }, { "command": "zowe.ds.editDataSet", "title": "%editDataSet%", @@ -529,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%", @@ -909,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", @@ -949,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", @@ -997,7 +1032,7 @@ { "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", @@ -1109,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", @@ -1134,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", @@ -1602,6 +1652,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" @@ -1714,6 +1772,18 @@ "command": "zowe.ds.sortBy", "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" @@ -2297,4 +2367,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/zowe-explorer/package.nls.json b/packages/zowe-explorer/package.nls.json index 492e623aa6..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", @@ -171,6 +173,9 @@ "jobs.filterBy": "Filter Jobs...", "ds.filterBy": "Filter PDS Members...", "ds.sortBy": "Sort PDS Members...", + "downloadDataSet": "Download Data Set...", + "downloadAllMembers": "Download All Members...", + "downloadMember": "Download Member...", "issueUnixCmd": "Issue Unix Command", "selectForCompare": "Select for Compare", "copyName": "Copy Name", @@ -187,4 +192,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/Constants.ts b/packages/zowe-explorer/src/configuration/Constants.ts index 0a241731c1..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 = 122; + 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; @@ -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/configuration/Definitions.ts b/packages/zowe-explorer/src/configuration/Definitions.ts index a24d7c8864..75ed799e52 100644 --- a/packages/zowe-explorer/src/configuration/Definitions.ts +++ b/packages/zowe-explorer/src/configuration/Definitions.ts @@ -39,6 +39,39 @@ export namespace Definitions { caseSensitive?: boolean; regex?: boolean; }; + export type DataSetDownloadOptions = { + overwrite?: boolean; + generateDirectory?: boolean; + preserveCase?: boolean; + binary?: boolean; + record?: boolean; + selectedPath?: vscode.Uri; + }; + export type UssDownloadOptions = { + overwrite?: boolean; + generateDirectory?: 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; + mtime?: number | string; + size?: number | string; + perm?: string; + type?: string; + depth?: number; + }; export type FavoriteData = { profileName: string; label: string; @@ -157,5 +190,8 @@ export namespace Definitions { V1_MIGRATION_STATUS = "zowe.v1MigrationStatus", DS_SEARCH_OPTIONS = "zowe.dsSearchOptions", 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/tools/ZoweLocalStorage.ts b/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts index cddd912840..8235adba65 100644 --- a/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts +++ b/packages/zowe-explorer/src/tools/ZoweLocalStorage.ts @@ -67,6 +67,8 @@ export class LocalStorageAccess extends ZoweLocalStorage { [Definitions.LocalStorageKey.ENCODING_HISTORY]: StorageAccessLevel.Read | StorageAccessLevel.Write, [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 9f24e5df5c..eb084ef291 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetActions.ts @@ -25,6 +25,7 @@ import { type AttributeInfo, DataSetAttributesProvider, ZosEncoding, + MessageSeverity, } from "@zowe/zowe-explorer-api"; import { ZoweDatasetNode } from "./ZoweDatasetNode"; import { DatasetUtils } from "./DatasetUtils"; @@ -44,6 +45,7 @@ import { Definitions } from "../../configuration/Definitions"; import { TreeViewUtils } from "../../utils/TreeViewUtils"; import { SharedTreeProviders } from "../shared/SharedTreeProviders"; import { DatasetTree } from "./DatasetTree"; +import { ZoweLocalStorage } from "../../tools/ZoweLocalStorage"; export class DatasetActions { public static typeEnum: zosfiles.CreateDataSetTypeEnum; @@ -611,6 +613,333 @@ export class DatasetActions { } } + private static async getDataSetDownloadOptions(): Promise { + const dataSetDownloadOptions: Definitions.DataSetDownloadOptions = + ZoweLocalStorage.getValue(Definitions.LocalStorageKey.DS_DOWNLOAD_OPTIONS) ?? {}; + + dataSetDownloadOptions.overwrite ??= true; + dataSetDownloadOptions.generateDirectory ??= false; + dataSetDownloadOptions.preserveCase ??= false; + dataSetDownloadOptions.binary ??= false; + dataSetDownloadOptions.record ??= false; + dataSetDownloadOptions.selectedPath ??= LocalFileManagement.getDefaultUri(); + + 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 members as binary files"), + picked: dataSetDownloadOptions.binary, + }, + { + label: vscode.l10n.t("Record"), + description: vscode.l10n.t("Download members 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); + return; + } + + 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; + const getOption = (label: string): boolean => selectedOptions.some((opt) => opt.label === vscode.l10n.t(label)); + 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); + + 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, + downloadType: string, + node: IZoweDatasetTreeNode + ): Promise { + await Gui.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: true, + }, + async (progress) => { + try { + const response = await downloadFn(progress); + void SharedUtils.handleDownloadResponse(response, downloadType); + } catch (e) { + await AuthUtils.errorHandling(e, { apiType: ZoweExplorerApiType.Mvs, profile: node.getProfile() }); + } + } + ); + } + + /** + * 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 || children.length === 0) { + Gui.showMessage(vscode.l10n.t("The selected data set has no members to download.")); + 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 DatasetActions.executeDownloadWithProgress( + vscode.l10n.t("Downloading all members"), + async (progress) => { + 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 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("Data set members"), + node + ); + } + + /** + * 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; + } + + const dataSetDownloadOptions = await DatasetActions.getDataSetDownloadOptions(); + if (!dataSetDownloadOptions) { + return; + } + const { overwrite, generateDirectory, preserveCase: preserveOriginalLetterCase, binary, record, selectedPath } = dataSetDownloadOptions; + + await DatasetActions.executeDownloadWithProgress( + vscode.l10n.t("Downloading member"), + async () => { + 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("Data set member"), + node + ); + } + + /** + * 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("Cannot download this type of data set.")); + return; + } + + const dataSetDownloadOptions = await DatasetActions.getDataSetDownloadOptions(); + if (!dataSetDownloadOptions) { + return; + } + const { overwrite, generateDirectory, preserveCase: preserveOriginalLetterCase, binary, record, selectedPath } = dataSetDownloadOptions; + + await DatasetActions.executeDownloadWithProgress( + vscode.l10n.t("Downloading data set"), + async () => { + const datasetName = node.getLabel() as string; + + // 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 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(datasetName, downloadOptions); + }, + vscode.l10n.t("Data set"), + node + ); + } + /** * 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 31ec742d33..58f7fe40af 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetInit.ts @@ -225,6 +225,20 @@ export class DatasetInit { vscode.commands.registerCommand("zowe.ds.listDataSets", async () => DatasetTableView.getInstance().handlePatternSearch(context)) ); + context.subscriptions.push( + vscode.commands.registerCommand("zowe.ds.downloadAllMembers", async (node: IZoweDatasetTreeNode) => + DatasetActions.downloadAllMembers(node) + ) + ); + + context.subscriptions.push( + 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; } diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts b/packages/zowe-explorer/src/trees/dataset/DatasetUtils.ts index e304c2805f..65b52a55ab 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"; import dayjs = require("dayjs"); @@ -158,4 +158,45 @@ 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, 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; + 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 otherwise match on anything containing "C" + continue; + } + if (matches.some((match) => (match instanceof RegExp ? match.test(label) : label.includes(match)))) { + extension = ext; + break; + } + } + + 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; + } + } + } + + return extensionMap; + } } diff --git a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts index ef10d21e62..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"; @@ -163,6 +164,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( @@ -206,9 +231,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 +247,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 +293,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 +316,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 +334,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,9 +398,9 @@ 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 items = SharedUtils.buildEncodingOptions(profile, taggedEncoding, false); let zosEncoding = await node.getEncoding(); if (zosEncoding === undefined && SharedUtils.isZoweUSSTreeNode(node)) { @@ -390,6 +433,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; } @@ -580,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 74238a689e..593feee926 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,9 @@ 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"; +import { USSUtils } from "./USSUtils"; export class USSActions { /** @@ -312,6 +315,505 @@ 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, + }, + ]; + + 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) { + 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., IBMUSER 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 downloadOpts: Definitions.UssDownloadOptions = + ZoweLocalStorage.getValue(Definitions.LocalStorageKey.USS_DOWNLOAD_OPTIONS) ?? {}; + + 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)"); + } + + 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[] = [ + { + label: vscode.l10n.t("Overwrite"), + description: isDirectory + ? vscode.l10n.t("Overwrite existing files when downloading directories") + : vscode.l10n.t("Overwrite existing file"), + picked: downloadOpts.overwrite, + }, + { + label: vscode.l10n.t("Generate Directory Structure"), + description: vscode.l10n.t("Generates sub-folders based on the USS path"), + picked: downloadOpts.generateDirectory, + }, + ]; + + // Add directory-specific options only when downloading directories + if (isDirectory) { + optionItems.push( + { + label: vscode.l10n.t("Include Hidden Files"), + 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: + 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: 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"); + 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 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(localizedLabels.includeHidden); + downloadOpts.dirOptions.filesys = getOption(localizedLabels.searchAllFilesystems); + downloadOpts.dirOptions.symlinks = getOption(localizedLabels.returnSymlinks); + downloadOpts.dirOptions.chooseFilterOptions = getOption(localizedLabels.setFilterOptions); + + if (getOption(localizedLabels.setFilterOptions)) { + const filterOptions = await USSActions.getUssDirFilterOptions(downloadOpts.dirFilterOptions); + if (filterOptions === null) { + return; + } + downloadOpts.dirFilterOptions = filterOptions; + } + } + + if (downloadOpts.chooseEncoding) { + if (isDirectory) { + const profile = node.getProfile(); + + downloadOpts.dirOptions.directoryEncoding = await SharedUtils.promptForDirectoryEncoding( + profile, + node.fullPath, + downloadOpts.dirOptions.directoryEncoding + ); + + 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; + } + } + } + + const dialogOptions: vscode.OpenDialogOptions = { + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: vscode.l10n.t("Select Download Location"), + defaultUri: downloadOpts.selectedPath, + }; + + const downloadPath = await Gui.showOpenDialog(dialogOptions); + if (!downloadPath || downloadPath.length === 0) { + return; + } + + const selectedPath = downloadPath[0].fsPath; + downloadOpts.selectedPath = vscode.Uri.file(selectedPath); + await ZoweLocalStorage.setValue(Definitions.LocalStorageKey.USS_DOWNLOAD_OPTIONS, downloadOpts); + + return downloadOpts; + } + + 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")); + 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 : profile.profile?.encoding, + overwrite: downloadOptions.overwrite, + }; + + try { + 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 }); + } + } + ); + } + + 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")); + return; + } + + 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; + } + + 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?", + totalFileCount + ), + { 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 = totalFileCount; + 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, + 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.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 { + if (token.isCancellationRequested) { + Gui.showMessage(vscode.l10n.t("Download cancelled")); + return; + } + + 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 }); + } + } + ); + } + 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 => { diff --git a/packages/zowe-explorer/src/trees/uss/USSUtils.ts b/packages/zowe-explorer/src/trees/uss/USSUtils.ts index 7e2c0a84aa..09de2b7e2b 100644 --- a/packages/zowe-explorer/src/trees/uss/USSUtils.ts +++ b/packages/zowe-explorer/src/trees/uss/USSUtils.ts @@ -17,6 +17,7 @@ import type { ZoweUSSNode } from "./ZoweUSSNode"; import { ZoweExplorerApiRegister } from "../../extending/ZoweExplorerApiRegister"; import { ZoweLogger } from "../../tools/ZoweLogger"; import { SharedContext } from "../shared/SharedContext"; +import { Constants } from "../../configuration/Constants"; export class USSUtils { /** @@ -109,4 +110,47 @@ export class USSUtils { return null; } } + + /** + * 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, maxDepth?: number, currentDepth: number = 1): Promise { + ZoweLogger.trace("uss.actions.countAllFilesRecursively called."); + let totalCount = 0; + + 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.dirty = true; + + const children = await node.getChildren(); + if (!children || children.length === 0) { + return 0; + } + + for (const child of children) { + if (SharedContext.isUssDirectory(child)) { + if (maxDepth === undefined || currentDepth < maxDepth) { + totalCount += await this.countAllFilesRecursively(child, maxDepth, currentDepth + 1); + } + } 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)}`); + return 0; + } + + return totalCount; + } } 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 {