From 95320f82841cc2600e490cacf52a26915e531909 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 30 Oct 2024 13:33:32 -0400 Subject: [PATCH 1/2] feat: 'Add to Workspace' context menu option (Data Sets & USS resources) (#3282) * feat: 'Add to Workspace' context menu option Signed-off-by: Trae Yelovich * resolve failing command-related tests Signed-off-by: Trae Yelovich * fix: remove extra divider from ds context menu Signed-off-by: Trae Yelovich * fix: catch errors during init for virtual workspace Signed-off-by: Trae Yelovich * refactor: log err.toString() -> err.message Signed-off-by: Trae Yelovich * chore: address changelog feedback Signed-off-by: Trae Yelovich * refactor: support USS sessions w/ search path; hide from files/PS Signed-off-by: Trae Yelovich --------- Signed-off-by: Trae Yelovich --- packages/zowe-explorer/CHANGELOG.md | 1 + .../__tests__/__mocks__/vscode.ts | 17 ++++ .../__tests__/__unit__/extension.unit.test.ts | 1 + .../trees/shared/SharedInit.unit.test.ts | 10 +++ .../trees/shared/SharedUtils.unit.test.ts | 77 +++++++++++++++++++ packages/zowe-explorer/package.json | 15 ++++ packages/zowe-explorer/package.nls.json | 1 + .../src/configuration/Constants.ts | 2 +- .../src/trees/shared/SharedInit.ts | 9 ++- .../src/trees/shared/SharedUtils.ts | 40 ++++++++++ 10 files changed, 171 insertions(+), 2 deletions(-) diff --git a/packages/zowe-explorer/CHANGELOG.md b/packages/zowe-explorer/CHANGELOG.md index e9e87e8e00..0610acd93b 100644 --- a/packages/zowe-explorer/CHANGELOG.md +++ b/packages/zowe-explorer/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the "vscode-extension-for-zowe" extension will be documen - Update Zowe SDKs to `8.2.0` to get the latest enhancements from Imperative. - Added expired JSON web token detection for profiles in each tree view (Data Sets, USS, Jobs). When a user performs a search on a profile, they are prompted to log in if their token expired. [#3175](https://github.com/zowe/zowe-explorer-vscode/issues/3175) +- Add a data set or USS resource to a virtual workspace with the new "Add to Workspace" context menu option. [#3265](https://github.com/zowe/zowe-explorer-vscode/issues/3265) ### Bug fixes diff --git a/packages/zowe-explorer/__tests__/__mocks__/vscode.ts b/packages/zowe-explorer/__tests__/__mocks__/vscode.ts index 5c32ad9803..0f9e3ad87a 100644 --- a/packages/zowe-explorer/__tests__/__mocks__/vscode.ts +++ b/packages/zowe-explorer/__tests__/__mocks__/vscode.ts @@ -1320,6 +1320,23 @@ export namespace workspace { export function onDidOpenTextDocument(listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) {} export function onDidSaveTextDocument(listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) {} + export function updateWorkspaceFolders( + start: number, + deleteCount: number | undefined | null, + ...workspaceFoldersToAdd: { + /** + * The uri of a workspace folder that's to be added. + */ + readonly uri: Uri; + /** + * The name of a workspace folder that's to be added. + */ + readonly name?: string; + }[] + ): boolean { + return false; + } + export function getConfiguration(configuration: string) { return { update: () => { diff --git a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts index d1a3631f85..2dc44959f5 100644 --- a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts @@ -233,6 +233,7 @@ async function createGlobalMocks() { "zowe.saveSearch", "zowe.addFavorite", "zowe.removeFavorite", + "zowe.addToWorkspace", "zowe.removeFavProfile", "zowe.openWithEncoding", "zowe.issueTsoCmd", diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedInit.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedInit.unit.test.ts index 600c3a11d9..a0e30bdc63 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedInit.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedInit.unit.test.ts @@ -555,6 +555,16 @@ describe("Test src/shared/extension", () => { expect(remoteLookupDsSpy).not.toHaveBeenCalled(); expect(remoteLookupUssSpy).not.toHaveBeenCalled(); }); + it("logs an error if one occurs", async () => { + const fakeEventInfo = getFakeEventInfo([{ uri: vscode.Uri.from({ scheme: ZoweScheme.DS, path: "/lpar.zosmf/TEST.PDS" }) }]); + const sampleError = new Error("issue fetching data set"); + const remoteLookupMock = jest.spyOn(DatasetFSProvider.instance, "remoteLookupForResource").mockRejectedValueOnce(sampleError); + const errorMock = jest.spyOn(ZoweLogger, "error").mockImplementation(); + await SharedInit.setupRemoteWorkspaceFolders(fakeEventInfo); + expect(errorMock).toHaveBeenCalledWith(sampleError.message); + expect(remoteLookupMock).toHaveBeenCalled(); + remoteLookupMock.mockRestore(); + }); }); describe("emitZoweEventHook", () => { 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 181ee8c6a9..cfd8324343 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 @@ -25,6 +25,7 @@ import { SharedUtils } from "../../../../src/trees/shared/SharedUtils"; import { ZoweUSSNode } from "../../../../src/trees/uss/ZoweUSSNode"; import { AuthUtils } from "../../../../src/utils/AuthUtils"; import { SharedTreeProviders } from "../../../../src/trees/shared/SharedTreeProviders"; +import { MockedProperty } from "../../../__mocks__/mockUtils"; function createGlobalMocks() { const newMocks = { @@ -551,3 +552,79 @@ describe("Shared utils unit tests - function parseFavorites", () => { expect(warnSpy).toHaveBeenCalledWith("Failed to parse a saved favorite. Attempted to parse: [testProfile]: "); }); }); + +describe("Shared utils unit tests - function addToWorkspace", () => { + it("adds a Data Set resource to the workspace", () => { + const datasetNode = new ZoweDatasetNode({ + label: "EXAMPLE.DS", + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextOverride: Constants.DS_DS_CONTEXT, + profile: createIProfile(), + }); + const updateWorkspaceFoldersMock = jest.spyOn(vscode.workspace, "updateWorkspaceFolders").mockImplementation(); + SharedUtils.addToWorkspace(datasetNode, null as any); + expect(updateWorkspaceFoldersMock).toHaveBeenCalledWith(0, null, { uri: datasetNode.resourceUri, name: datasetNode.label as string }); + }); + it("adds a USS resource to the workspace", () => { + const ussNode = new ZoweUSSNode({ + label: "textFile.txt", + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextOverride: Constants.USS_TEXT_FILE_CONTEXT, + profile: createIProfile(), + }); + const updateWorkspaceFoldersMock = jest.spyOn(vscode.workspace, "updateWorkspaceFolders").mockImplementation(); + SharedUtils.addToWorkspace(ussNode, null as any); + expect(updateWorkspaceFoldersMock).toHaveBeenCalledWith(0, null, { uri: ussNode.resourceUri, name: ussNode.label as string }); + }); + it("adds a USS session w/ fullPath to the workspace", () => { + const ussNode = new ZoweUSSNode({ + label: "sestest", + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextOverride: Constants.USS_SESSION_CONTEXT, + profile: createIProfile(), + session: createISession(), + }); + ussNode.fullPath = "/u/users/smpluser"; + const updateWorkspaceFoldersMock = jest.spyOn(vscode.workspace, "updateWorkspaceFolders").mockImplementation(); + SharedUtils.addToWorkspace(ussNode, null as any); + expect(updateWorkspaceFoldersMock).toHaveBeenCalledWith(0, null, { + uri: ussNode.resourceUri?.with({ path: `/sestest${ussNode.fullPath}` }), + name: `[${ussNode.label as string}] ${ussNode.fullPath}`, + }); + }); + it("displays an info message when adding a USS session w/o fullPath", () => { + const ussNode = new ZoweUSSNode({ + label: "sestest", + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextOverride: Constants.USS_SESSION_CONTEXT, + profile: createIProfile(), + session: createISession(), + }); + ussNode.fullPath = ""; + const updateWorkspaceFoldersMock = jest.spyOn(vscode.workspace, "updateWorkspaceFolders").mockImplementation(); + const infoMessageSpy = jest.spyOn(Gui, "infoMessage"); + updateWorkspaceFoldersMock.mockClear(); + SharedUtils.addToWorkspace(ussNode, null as any); + expect(updateWorkspaceFoldersMock).not.toHaveBeenCalledWith(0, null, { + uri: ussNode.resourceUri?.with({ path: `/sestest${ussNode.fullPath}` }), + name: `[${ussNode.label as string}] ${ussNode.fullPath}`, + }); + expect(infoMessageSpy).toHaveBeenCalledWith("A search must be set for sestest before it can be added to a workspace."); + }); + it("skips adding a resource that's already in the workspace", () => { + const ussNode = new ZoweUSSNode({ + label: "textFile.txt", + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextOverride: Constants.USS_TEXT_FILE_CONTEXT, + profile: createIProfile(), + }); + const workspaceFolders = new MockedProperty(vscode.workspace, "workspaceFolders", { + value: [{ uri: ussNode.resourceUri, name: ussNode.label }], + }); + const updateWorkspaceFoldersMock = jest.spyOn(vscode.workspace, "updateWorkspaceFolders").mockImplementation(); + updateWorkspaceFoldersMock.mockClear(); + SharedUtils.addToWorkspace(ussNode, null as any); + expect(updateWorkspaceFoldersMock).not.toHaveBeenCalledWith(0, null, { uri: ussNode.resourceUri, name: ussNode.label as string }); + workspaceFolders[Symbol.dispose](); + }); +}); diff --git a/packages/zowe-explorer/package.json b/packages/zowe-explorer/package.json index 14ea967bbc..a55bd6954f 100644 --- a/packages/zowe-explorer/package.json +++ b/packages/zowe-explorer/package.json @@ -211,6 +211,11 @@ "light": "./resources/light/favorites-open-light.svg" } }, + { + "command": "zowe.addToWorkspace", + "title": "%addToWorkspace%", + "category": "Zowe Explorer" + }, { "command": "zowe.ds.addSession", "title": "%addSession%", @@ -839,6 +844,11 @@ "command": "zowe.removeFavorite", "group": "003_zowe_ussWorkspace@1" }, + { + "when": "view == zowe.uss.explorer && viewItem =~ /^(directory.*|ussSession.*)/", + "command": "zowe.addToWorkspace", + "group": "003_zowe_ussWorkspace@2" + }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!.*_fav.*)ussSession.*/ && !listMultiSelection", "command": "zowe.addFavorite", @@ -1009,6 +1019,11 @@ "command": "zowe.removeFavorite", "group": "002_zowe_dsWorkspace@1" }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^pds.*/", + "command": "zowe.addToWorkspace", + "group": "002_zowe_dsWorkspace@2" + }, { "when": "view == zowe.ds.explorer && viewItem == profile_fav && !listMultiSelection", "command": "zowe.removeFavProfile", diff --git a/packages/zowe-explorer/package.nls.json b/packages/zowe-explorer/package.nls.json index b21aa2e228..690853c3a1 100644 --- a/packages/zowe-explorer/package.nls.json +++ b/packages/zowe-explorer/package.nls.json @@ -20,6 +20,7 @@ "diff.overwrite": "Overwrite", "diff.useRemote": "Use Remote", "addFavorite": "Add to Favorites", + "addToWorkspace": "Add to Workspace", "removeFavProfile": "Remove profile from Favorites", "addSession": "Add Profile to Data Sets View", "createDataset": "Create New Data Set", diff --git a/packages/zowe-explorer/src/configuration/Constants.ts b/packages/zowe-explorer/src/configuration/Constants.ts index 24a965867f..99a1181afe 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 = 99; + public static readonly COMMAND_COUNT = 100; public static readonly MAX_SEARCH_HISTORY = 5; public static readonly MAX_FILE_HISTORY = 10; public static readonly MS_PER_SEC = 1000; diff --git a/packages/zowe-explorer/src/trees/shared/SharedInit.ts b/packages/zowe-explorer/src/trees/shared/SharedInit.ts index daefdba139..b1fa5eb768 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedInit.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedInit.ts @@ -214,6 +214,7 @@ export class SharedInit { } }) ); + context.subscriptions.push(vscode.commands.registerCommand("zowe.addToWorkspace", SharedUtils.addToWorkspace)); context.subscriptions.push( vscode.commands.registerCommand("zowe.removeFavProfile", (node: IZoweTreeNode) => SharedTreeProviders.getProviderForNode(node).removeFavProfile(node.label as string, true) @@ -393,7 +394,13 @@ export class SharedInit { (f) => f.uri.scheme === ZoweScheme.DS || f.uri.scheme === ZoweScheme.USS ); for (const folder of newWorkspaces) { - await (folder.uri.scheme === ZoweScheme.DS ? DatasetFSProvider.instance : UssFSProvider.instance).remoteLookupForResource(folder.uri); + try { + await (folder.uri.scheme === ZoweScheme.DS ? DatasetFSProvider.instance : UssFSProvider.instance).remoteLookupForResource(folder.uri); + } catch (err) { + if (err instanceof Error) { + ZoweLogger.error(err.message); + } + } } } diff --git a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts index 0288f84d91..25da513c52 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts @@ -287,4 +287,44 @@ export class SharedUtils { public static getSessionLabel(node: IZoweTreeNode): string { return (SharedContext.isSession(node) ? node : node.getSessionNode()).label as string; } + + /** + * Adds one or more Data Sets/USS nodes to a workspace. + * @param node Single node selection + * @param nodeList List of selected nodes + */ + public static addToWorkspace( + this: void, + node: IZoweUSSTreeNode | IZoweDatasetTreeNode, + nodeList: IZoweUSSTreeNode[] | IZoweDatasetTreeNode[] + ): void { + const workspaceFolders = vscode.workspace.workspaceFolders; + const selectedNodes = SharedUtils.getSelectedNodeList(node, nodeList); + for (const item of selectedNodes) { + let resourceUri = item.resourceUri; + const isSession = SharedContext.isSession(item); + if (isSession) { + if (item.fullPath?.length > 0) { + resourceUri = item.resourceUri.with({ path: path.posix.join(item.resourceUri.path, item.fullPath) }); + } else { + Gui.infoMessage( + vscode.l10n.t({ + message: "A search must be set for {0} before it can be added to a workspace.", + args: [item.label as string], + comment: "Name of USS session", + }) + ); + continue; + } + } + if (workspaceFolders?.some((folder) => folder.uri === resourceUri)) { + continue; + } + + vscode.workspace.updateWorkspaceFolders(workspaceFolders?.length ?? 0, null, { + uri: resourceUri, + name: isSession ? `[${item.label as string}] ${item.fullPath}` : (item.label as string), + }); + } + } } From 1786fe3f091d6ca7938478c067605a13d798937b Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 30 Oct 2024 15:56:28 -0400 Subject: [PATCH 2/2] feat: open Zowe Explorer resources using VSCode URLs (#3271) * feat: open Zowe Explorer resources using VSCode URLs Signed-off-by: Trae Yelovich * fix failing tests, add new tests, fix Uri.parse stubs Signed-off-by: Trae Yelovich * chore: update ZE changelog Signed-off-by: Trae Yelovich * default value for Uri.path stub Signed-off-by: Trae Yelovich * feat: 'Copy External Link' for nodes in context menu Signed-off-by: Trae Yelovich * refactor: use context.extension.id to build URI Signed-off-by: Trae Yelovich * chore: incorporate changelog feedback Signed-off-by: Trae Yelovich * update command count after merge Signed-off-by: Trae Yelovich * refactor: Hide option from directories/PDS We're not in a place to easily support directories and PDS right now. We can add support for this in the future once its easy to access the profile node and execute a search on it programatically. Signed-off-by: Trae Yelovich --------- Signed-off-by: Trae Yelovich --- .../zowe-explorer-api/__mocks__/vscode.ts | 20 +++++-- packages/zowe-explorer/CHANGELOG.md | 1 + .../__tests__/__mocks__/vscode.ts | 59 +++++++++++++++++-- .../__tests__/__unit__/extension.unit.test.ts | 1 + .../trees/shared/SharedUtils.unit.test.ts | 18 ++++++ .../__unit__/utils/UriHandler.unit.test.ts | 35 +++++++++++ packages/zowe-explorer/l10n/bundle.l10n.json | 52 ++++++++-------- packages/zowe-explorer/l10n/poeditor.json | 19 +++--- packages/zowe-explorer/package.json | 24 +++++++- packages/zowe-explorer/package.nls.json | 1 + .../src/configuration/Constants.ts | 2 +- .../src/trees/shared/SharedInit.ts | 5 ++ .../src/trees/shared/SharedUtils.ts | 6 ++ .../zowe-explorer/src/utils/UriHandler.ts | 34 +++++++++++ 14 files changed, 230 insertions(+), 47 deletions(-) create mode 100644 packages/zowe-explorer/__tests__/__unit__/utils/UriHandler.unit.test.ts create mode 100644 packages/zowe-explorer/src/utils/UriHandler.ts diff --git a/packages/zowe-explorer-api/__mocks__/vscode.ts b/packages/zowe-explorer-api/__mocks__/vscode.ts index f1a55ae1f1..839decdfa3 100644 --- a/packages/zowe-explorer-api/__mocks__/vscode.ts +++ b/packages/zowe-explorer-api/__mocks__/vscode.ts @@ -611,14 +611,24 @@ export interface TreeDataProvider { } export class Uri { + private static _regexp = /^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/; + public static file(path: string): Uri { return Uri.parse(path); } public static parse(value: string, _strict?: boolean): Uri { - const newUri = new Uri(); - newUri.path = value; + const match = Uri._regexp.exec(value); + if (!match) { + return new Uri(); + } - return newUri; + return Uri.from({ + scheme: match[2] || "", + authority: match[4] || "", + path: match[5] || "", + query: match[7] || "", + fragment: match[9] || "", + }); } public with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): Uri { @@ -688,7 +698,7 @@ export class Uri { /** * Path is the `/some/path` part of `http://www.example.com/some/path?query#fragment`. */ - path: string; + path: string = ""; /** * Query is the `query` part of `http://www.example.com/some/path?query#fragment`. @@ -720,7 +730,7 @@ export class Uri { * u.fsPath === '\\server\c$\folder\file.txt' * ``` */ - fsPath: string; + fsPath: string = ""; public toString(): string { let result = this.scheme ? `${this.scheme}://` : ""; diff --git a/packages/zowe-explorer/CHANGELOG.md b/packages/zowe-explorer/CHANGELOG.md index 0610acd93b..b48f7b509a 100644 --- a/packages/zowe-explorer/CHANGELOG.md +++ b/packages/zowe-explorer/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the "vscode-extension-for-zowe" extension will be documen - Update Zowe SDKs to `8.2.0` to get the latest enhancements from Imperative. - Added expired JSON web token detection for profiles in each tree view (Data Sets, USS, Jobs). When a user performs a search on a profile, they are prompted to log in if their token expired. [#3175](https://github.com/zowe/zowe-explorer-vscode/issues/3175) - Add a data set or USS resource to a virtual workspace with the new "Add to Workspace" context menu option. [#3265](https://github.com/zowe/zowe-explorer-vscode/issues/3265) +- Power users and developers can now build links to efficiently open mainframe resources in Zowe Explorer. Use the **Copy External Link** option in the context menu to get the URL for a data set or USS resource, or create a link in the format `vscode://Zowe.vscode-extension-for-zowe?`. For more information on building resource URIs, see the [FileSystemProvider wiki article](https://github.com/zowe/zowe-explorer-vscode/wiki/FileSystemProvider#file-paths-vs-uris). [#3271](https://github.com/zowe/zowe-explorer-vscode/pull/3271) ### Bug fixes diff --git a/packages/zowe-explorer/__tests__/__mocks__/vscode.ts b/packages/zowe-explorer/__tests__/__mocks__/vscode.ts index 0f9e3ad87a..5e81e6e762 100644 --- a/packages/zowe-explorer/__tests__/__mocks__/vscode.ts +++ b/packages/zowe-explorer/__tests__/__mocks__/vscode.ts @@ -518,7 +518,47 @@ export interface WebviewViewProvider { resolveWebviewView(webviewView: WebviewView, context: WebviewViewResolveContext, token: CancellationToken): Thenable | void; } +/** + * A uri handler is responsible for handling system-wide {@link Uri uris}. + * + * @see {@link window.registerUriHandler}. + */ +export interface UriHandler { + /** + * Handle the provided system-wide {@link Uri}. + * + * @see {@link window.registerUriHandler}. + */ + handleUri(uri: Uri): ProviderResult; +} + export namespace window { + /** + * Registers a {@link UriHandler uri handler} capable of handling system-wide {@link Uri uris}. + * In case there are multiple windows open, the topmost window will handle the uri. + * A uri handler is scoped to the extension it is contributed from; it will only + * be able to handle uris which are directed to the extension itself. A uri must respect + * the following rules: + * + * - The uri-scheme must be `vscode.env.uriScheme`; + * - The uri-authority must be the extension id (e.g. `my.extension`); + * - The uri-path, -query and -fragment parts are arbitrary. + * + * For example, if the `my.extension` extension registers a uri handler, it will only + * be allowed to handle uris with the prefix `product-name://my.extension`. + * + * An extension can only register a single uri handler in its entire activation lifetime. + * + * * *Note:* There is an activation event `onUri` that fires when a uri directed for + * the current extension is about to be handled. + * + * @param handler The uri handler to register for this extension. + * @returns A {@link Disposable disposable} that unregisters the handler. + */ + export function registerUriHandler(handler: UriHandler): Disposable { + return () => {}; + } + /** * Register a new provider for webview views. * @@ -1529,14 +1569,23 @@ export interface TextDocument { } export class Uri { + private static _regexp = /^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/; public static file(path: string): Uri { return Uri.parse(path); } - public static parse(value: string, strict?: boolean): Uri { - const newUri = new Uri(); - newUri.path = value; + public static parse(value: string, _strict?: boolean): Uri { + const match = Uri._regexp.exec(value); + if (!match) { + return new Uri(); + } - return newUri; + return Uri.from({ + scheme: match[2] || "", + authority: match[4] || "", + path: match[5] || "", + query: match[7] || "", + fragment: match[9] || "", + }); } public with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): Uri { @@ -1606,7 +1655,7 @@ export class Uri { /** * Path is the `/some/path` part of `http://www.example.com/some/path?query#fragment`. */ - path: string; + path: string = ""; /** * Query is the `query` part of `http://www.example.com/some/path?query#fragment`. diff --git a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts index 2dc44959f5..d48df63991 100644 --- a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts @@ -243,6 +243,7 @@ async function createGlobalMocks() { "zowe.compareWithSelected", "zowe.compareWithSelectedReadOnly", "zowe.compareFileStarted", + "zowe.copyExternalLink", "zowe.placeholderCommand", ], }; 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 cfd8324343..5230a16358 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 @@ -12,6 +12,7 @@ import * as vscode from "vscode"; import { createIProfile, createISession, createInstanceOfProfile } from "../../../__mocks__/mockCreators/shared"; import { createDatasetSessionNode } from "../../../__mocks__/mockCreators/datasets"; +import { createUSSNode } from "../../../__mocks__/mockCreators/uss"; import { UssFSProvider } from "../../../../src/trees/uss/UssFSProvider"; import { imperative, ProfilesCache, Gui, ZosEncoding, BaseProvider } from "@zowe/zowe-explorer-api"; import { Constants } from "../../../../src/configuration/Constants"; @@ -628,3 +629,20 @@ describe("Shared utils unit tests - function addToWorkspace", () => { workspaceFolders[Symbol.dispose](); }); }); + +describe("Shared utils unit tests - function copyExternalLink", () => { + it("does nothing for an invalid node or one without a resource URI", async () => { + const copyClipboardMock = jest.spyOn(vscode.env.clipboard, "writeText"); + const ussNode = createUSSNode(createISession(), createIProfile()); + ussNode.resourceUri = undefined; + await SharedUtils.copyExternalLink({ extension: { id: "Zowe.vscode-extension-for-zowe" } } as any, ussNode); + expect(copyClipboardMock).not.toHaveBeenCalled(); + }); + + it("copies a link for a node with a resource URI", async () => { + const copyClipboardMock = jest.spyOn(vscode.env.clipboard, "writeText"); + const ussNode = createUSSNode(createISession(), createIProfile()); + await SharedUtils.copyExternalLink({ extension: { id: "Zowe.vscode-extension-for-zowe" } } as any, ussNode); + expect(copyClipboardMock).toHaveBeenCalledWith(`vscode://Zowe.vscode-extension-for-zowe?${ussNode.resourceUri?.toString()}`); + }); +}); diff --git a/packages/zowe-explorer/__tests__/__unit__/utils/UriHandler.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/utils/UriHandler.unit.test.ts new file mode 100644 index 0000000000..c6a285ed36 --- /dev/null +++ b/packages/zowe-explorer/__tests__/__unit__/utils/UriHandler.unit.test.ts @@ -0,0 +1,35 @@ +/** + * 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 { commands, Uri } from "vscode"; +import { ZoweUriHandler } from "../../../src/utils/UriHandler"; + +describe("ZoweUriHandler", () => { + function getBlockMocks() { + return { + executeCommand: jest.spyOn(commands, "executeCommand"), + }; + } + + it("does nothing if the parsed query does not start with a Zowe scheme", async () => { + const blockMocks = getBlockMocks(); + await ZoweUriHandler.getInstance().handleUri(Uri.parse("vscode://Zowe.vscode-extension-for-zowe?blah-some-unknown-query")); + expect(blockMocks.executeCommand).not.toHaveBeenCalled(); + }); + + it("calls vscode.open with the parsed URI if a Zowe resource URI was provided", async () => { + const blockMocks = getBlockMocks(); + const uri = Uri.parse("vscode://Zowe.vscode-extension-for-zowe?zowe-ds:/lpar.zosmf/TEST.PS"); + await ZoweUriHandler.getInstance().handleUri(uri); + const zoweUri = Uri.parse(uri.query); + expect(blockMocks.executeCommand).toHaveBeenCalledWith("vscode.open", zoweUri, { preview: false }); + }); +}); diff --git a/packages/zowe-explorer/l10n/bundle.l10n.json b/packages/zowe-explorer/l10n/bundle.l10n.json index a58bb857eb..c6dec33e62 100644 --- a/packages/zowe-explorer/l10n/bundle.l10n.json +++ b/packages/zowe-explorer/l10n/bundle.l10n.json @@ -208,32 +208,6 @@ "Profile auth error": "Profile auth error", "Profile is not authenticated, please log in to continue": "Profile is not authenticated, please log in to continue", "Retrieving response from USS list API": "Retrieving response from USS list API", - "The 'move' function is not implemented for this USS API.": "The 'move' function is not implemented for this USS API.", - "Could not list USS files: Empty path provided in URI": "Could not list USS files: Empty path provided in URI", - "Profile does not exist for this file.": "Profile does not exist for this file.", - "Saving USS file...": "Saving USS file...", - "Renaming {0} failed due to API error: {1}/File pathError message": { - "message": "Renaming {0} failed due to API error: {1}", - "comment": [ - "File path", - "Error message" - ] - }, - "Deleting {0} failed due to API error: {1}/File nameError message": { - "message": "Deleting {0} failed due to API error: {1}", - "comment": [ - "File name", - "Error message" - ] - }, - "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" - ] - }, "Downloaded: {0}/Download time": { "message": "Downloaded: {0}", "comment": [ @@ -304,6 +278,32 @@ "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.", + "Could not list USS files: Empty path provided in URI": "Could not list USS files: Empty path provided in URI", + "Profile does not exist for this file.": "Profile does not exist for this file.", + "Saving USS file...": "Saving USS file...", + "Renaming {0} failed due to API error: {1}/File pathError message": { + "message": "Renaming {0} failed due to API error: {1}", + "comment": [ + "File path", + "Error message" + ] + }, + "Deleting {0} failed due to API error: {1}/File nameError message": { + "message": "Deleting {0} failed due to API error: {1}", + "comment": [ + "File name", + "Error message" + ] + }, + "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" + ] + }, "{0} location/Node type": { "message": "{0} location", "comment": [ diff --git a/packages/zowe-explorer/l10n/poeditor.json b/packages/zowe-explorer/l10n/poeditor.json index 71ae3ec2e0..b63316cbec 100644 --- a/packages/zowe-explorer/l10n/poeditor.json +++ b/packages/zowe-explorer/l10n/poeditor.json @@ -407,6 +407,9 @@ "openWithEncoding": { "Open with Encoding": "" }, + "copyExternalLink": { + "Copy External Link": "" + }, "zowe.history.deprecationMsg": { "Changes made here will not be reflected in Zowe Explorer, use right-click Edit History option to access information from local storage.": "" }, @@ -528,14 +531,6 @@ "Profile auth error": "", "Profile is not authenticated, please log in to continue": "", "Retrieving response from USS list API": "", - "The 'move' function is not implemented for this USS API.": "", - "Could not list USS files: Empty path provided in URI": "", - "Profile does not exist for this file.": "", - "Saving USS file...": "", - "Renaming {0} failed due to API error: {1}": "", - "Deleting {0} failed due to API error: {1}": "", - "No error details given": "", - "Error fetching destination {0} for paste action: {1}": "", "Downloaded: {0}": "", "Encoding: {0}": "", "Binary": "", @@ -564,6 +559,14 @@ "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.": "", + "Could not list USS files: Empty path provided in URI": "", + "Profile does not exist for this file.": "", + "Saving USS file...": "", + "Renaming {0} failed due to API error: {1}": "", + "Deleting {0} failed due to API error: {1}": "", + "No error details given": "", + "Error fetching destination {0} for paste action: {1}": "", "{0} location": "", "Choose a location to create the {0}": "", "Name of file or directory": "", diff --git a/packages/zowe-explorer/package.json b/packages/zowe-explorer/package.json index a55bd6954f..da496da3d2 100644 --- a/packages/zowe-explorer/package.json +++ b/packages/zowe-explorer/package.json @@ -409,6 +409,11 @@ "title": "%copyName%", "category": "Zowe Explorer" }, + { + "command": "zowe.copyExternalLink", + "title": "%copyExternalLink%", + "category": "Zowe Explorer" + }, { "command": "zowe.uss.addSession", "title": "%uss.addSession%", @@ -834,6 +839,11 @@ "command": "zowe.uss.copyRelativePath", "group": "002_zowe_ussSystemSpecific@5" }, + { + "when": "view == zowe.uss.explorer && viewItem =~ /^(?!(directory|favorite|profile_fav|ussSession))/ && !listMultiSelection", + "command": "zowe.copyExternalLink", + "group": "002_zowe_ussSystemSpecific@6" + }, { "when": "view == zowe.uss.explorer && viewItem =~ /^(?!.*_fav.*)(textFile.*|binaryFile.*|directory.*)/", "command": "zowe.addFavorite", @@ -1034,15 +1044,20 @@ "command": "zowe.ds.copyName", "group": "098_zowe_dsMisc@0" }, + { + "when": "view == zowe.ds.explorer && viewItem =~ /^(ds|member).*/ && !listMultiSelection", + "command": "zowe.copyExternalLink", + "group": "098_zowe_dsMisc@1" + }, { "when": "view == zowe.ds.explorer && viewItem =~ /^(pds|session).*/ && !listMultiSelection", "command": "zowe.ds.filterBy", - "group": "098_zowe_dsMisc@1" + "group": "098_zowe_dsMisc@2" }, { "when": "view == zowe.ds.explorer && viewItem =~ /^(pds|session).*/ && !listMultiSelection", "command": "zowe.ds.sortBy", - "group": "098_zowe_dsMisc@2" + "group": "098_zowe_dsMisc@3" }, { "when": "view == zowe.ds.explorer && viewItem =~ /^fileError.*/", @@ -1219,6 +1234,11 @@ "command": "zowe.jobs.copyName", "group": "003_zowe_jobsMisc@0" }, + { + "when": "view == zowe.jobs.explorer && viewItem =~ /^spool.*/", + "command": "zowe.copyExternalLink", + "group": "003_zowe_jobsMisc@1" + }, { "when": "view == zowe.jobs.explorer && viewItem =~ /^spool.*/", "command": "zowe.jobs.refreshSpool", diff --git a/packages/zowe-explorer/package.nls.json b/packages/zowe-explorer/package.nls.json index 690853c3a1..871ff23d65 100644 --- a/packages/zowe-explorer/package.nls.json +++ b/packages/zowe-explorer/package.nls.json @@ -136,5 +136,6 @@ "compareWithSelected": "Compare with Selected", "compareWithSelectedReadOnly": "Compare with Selected (Read-Only)", "openWithEncoding": "Open with Encoding", + "copyExternalLink": "Copy External Link", "zowe.history.deprecationMsg": "Changes made here will not be reflected in Zowe Explorer, use right-click Edit History option to access information from local storage." } diff --git a/packages/zowe-explorer/src/configuration/Constants.ts b/packages/zowe-explorer/src/configuration/Constants.ts index 99a1181afe..33e6406262 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 = 100; + public static readonly COMMAND_COUNT = 101; public static readonly MAX_SEARCH_HISTORY = 5; public static readonly MAX_FILE_HISTORY = 10; public static readonly MS_PER_SEC = 1000; diff --git a/packages/zowe-explorer/src/trees/shared/SharedInit.ts b/packages/zowe-explorer/src/trees/shared/SharedInit.ts index b1fa5eb768..f275ef658e 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedInit.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedInit.ts @@ -46,6 +46,7 @@ import { SharedContext } from "./SharedContext"; import { TreeViewUtils } from "../../utils/TreeViewUtils"; import { CertificateWizard } from "../../utils/CertificateWizard"; import { ZosConsoleViewProvider } from "../../zosconsole/ZosConsolePanel"; +import { ZoweUriHandler } from "../../utils/UriHandler"; export class SharedInit { private static originalEmitZoweEvent: typeof imperative.EventProcessor.prototype.emitEvent; @@ -277,6 +278,10 @@ export class SharedInit { return LocalFileManagement.fileSelectedToCompare; }) ); + context.subscriptions.push( + vscode.commands.registerCommand("zowe.copyExternalLink", (node: IZoweTreeNode) => SharedUtils.copyExternalLink(context, node)) + ); + context.subscriptions.push(vscode.window.registerUriHandler(ZoweUriHandler.getInstance())); context.subscriptions.push( vscode.commands.registerCommand("zowe.placeholderCommand", () => { // This command does nothing, its here to let us disable individual items in the tree view diff --git a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts index 25da513c52..b68958cab8 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedUtils.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedUtils.ts @@ -23,6 +23,12 @@ import { SharedContext } from "./SharedContext"; import { Definitions } from "../../configuration/Definitions"; export class SharedUtils { + public static async copyExternalLink(this: void, context: vscode.ExtensionContext, node: IZoweTreeNode): Promise { + if (node?.resourceUri != null) { + await vscode.env.clipboard.writeText(`vscode://${context.extension.id}?${node.resourceUri.toString()}`); + } + } + public static filterTreeByString(value: string, treeItems: vscode.QuickPickItem[]): vscode.QuickPickItem[] { ZoweLogger.trace("shared.utils.filterTreeByString called."); const filteredArray: vscode.QuickPickItem[] = []; diff --git a/packages/zowe-explorer/src/utils/UriHandler.ts b/packages/zowe-explorer/src/utils/UriHandler.ts new file mode 100644 index 0000000000..1ba9e89115 --- /dev/null +++ b/packages/zowe-explorer/src/utils/UriHandler.ts @@ -0,0 +1,34 @@ +/** + * 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 { commands, ProviderResult, Uri, UriHandler } from "vscode"; +import { ZoweScheme } from "../../../zowe-explorer-api/src"; + +export class ZoweUriHandler implements UriHandler { + private static instance: ZoweUriHandler = null; + private constructor() {} + + public static getInstance(): ZoweUriHandler { + if (ZoweUriHandler.instance == null) { + ZoweUriHandler.instance = new ZoweUriHandler(); + } + + return ZoweUriHandler.instance; + } + + public handleUri(uri: Uri): ProviderResult { + const parsedUri = Uri.parse(uri.query); + if (!Object.values(ZoweScheme).some((scheme) => scheme === parsedUri.scheme)) { + return; + } + return commands.executeCommand("vscode.open", parsedUri, { preview: false }); + } +}