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 }); + } +}