diff --git a/packages/samples/README.md b/packages/samples/README.md index 715dc15ab8..782b474683 100644 --- a/packages/samples/README.md +++ b/packages/samples/README.md @@ -6,4 +6,4 @@ This folder contains sample packages for reference during development, for both ## vue-webview-sample -Demonstrates the use of the `WebView` class from Zowe Explorer API to create a webview panel, powered by the Vite bundler and Vue JavaScript framework. \ No newline at end of file +Demonstrates the use of the `WebView` class from Zowe Explorer API to create a webview panel, powered by the Vite bundler and Vue JavaScript framework. diff --git a/packages/zowe-explorer/__mocks__/vscode.ts b/packages/zowe-explorer/__mocks__/vscode.ts index ef3457f8d9..c479dbf6c4 100644 --- a/packages/zowe-explorer/__mocks__/vscode.ts +++ b/packages/zowe-explorer/__mocks__/vscode.ts @@ -9,6 +9,57 @@ * */ +export enum ViewColumn { + /** + * A *symbolic* editor column representing the currently active column. This value + * can be used when opening editors, but the *resolved* {@link TextEditor.viewColumn viewColumn}-value + * of editors will always be `One`, `Two`, `Three`,... or `undefined` but never `Active`. + */ + Active = -1, + /** + * A *symbolic* editor column representing the column to the side of the active one. This value + * can be used when opening editors, but the *resolved* {@link TextEditor.viewColumn viewColumn}-value + * of editors will always be `One`, `Two`, `Three`,... or `undefined` but never `Beside`. + */ + Beside = -2, + /** + * The first editor column. + */ + One = 1, + /** + * The second editor column. + */ + Two = 2, + /** + * The third editor column. + */ + Three = 3, + /** + * The fourth editor column. + */ + Four = 4, + /** + * The fifth editor column. + */ + Five = 5, + /** + * The sixth editor column. + */ + Six = 6, + /** + * The seventh editor column. + */ + Seven = 7, + /** + * The eighth editor column. + */ + Eight = 8, + /** + * The ninth editor column. + */ + Nine = 9, +} + /** * A provider result represents the values a provider, like the [`HoverProvider`](#HoverProvider), * may return. For once this is the actual result type `T`, like `Hover`, or a thenable that resolves @@ -239,6 +290,22 @@ export namespace window { return this; } + export function createWebviewPanel( + viewType: string, + title: string, + showOptions: ViewColumn | { viewColumn: ViewColumn; preserveFocus?: boolean }, + options?: any + ): any { + return { + onDidDispose: jest.fn(), + webview: { + asWebviewUri: jest.fn(), + postMessage: jest.fn(), + onDidReceiveMessage: jest.fn(), + }, + }; + } + /** * Options to configure the behavior of the message. * diff --git a/packages/zowe-explorer/__tests__/__unit__/uss/AttributeView.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/uss/AttributeView.unit.test.ts new file mode 100644 index 0000000000..439bd82732 --- /dev/null +++ b/packages/zowe-explorer/__tests__/__unit__/uss/AttributeView.unit.test.ts @@ -0,0 +1,101 @@ +import { ExtensionContext } from "vscode"; +import { AttributeView } from "../../../src/uss/AttributeView"; +import { IZoweTree, IZoweUSSTreeNode, ZoweExplorerApi } from "@zowe/zowe-explorer-api"; +import { ZoweExplorerApiRegister } from "../../../src/ZoweExplorerApiRegister"; + +describe("AttributeView unit tests", () => { + let view: AttributeView; + const context = { extensionPath: "some/fake/ext/path" } as unknown as ExtensionContext; + const treeProvider = { refreshElement: jest.fn(), refresh: jest.fn() } as unknown as IZoweTree; + const node = { + attributes: { + perms: "----------", + }, + label: "example node", + fullPath: "/z/some/path", + getParent: jest.fn(), + getProfile: jest.fn(), + onUpdate: jest.fn(), + } as unknown as IZoweUSSTreeNode; + const updateAttributesMock = jest.fn(); + + beforeAll(() => { + jest.spyOn(ZoweExplorerApiRegister, "getUssApi").mockReturnValue({ + updateAttributes: updateAttributesMock, + } as unknown as ZoweExplorerApi.IUss); + view = new AttributeView(context, treeProvider, node); + }); + + afterEach(() => { + node.onUpdate = jest.fn(); + }); + + it("refreshes properly when webview sends 'refresh' command", () => { + // case 1: node is a root node + (view as any).onDidReceiveMessage({ command: "refresh" }); + expect(treeProvider.refresh).toHaveBeenCalled(); + + // case 2: node is a child node + node.getParent = jest.fn().mockReturnValueOnce({ label: "parent node" } as IZoweUSSTreeNode); + (view as any).onDidReceiveMessage({ command: "refresh" }); + expect(treeProvider.refreshElement).toHaveBeenCalled(); + + expect(node.onUpdate).toHaveBeenCalledTimes(2); + }); + + it("dispatches node data to webview when 'ready' command is received", () => { + (view as any).onDidReceiveMessage({ command: "ready" }); + expect(view.panel.webview.postMessage).toHaveBeenCalledWith({ + attributes: node.attributes, + name: node.fullPath, + readonly: false, + }); + }); + + it("updates attributes when 'update-attributes' command is received", async () => { + // case 1: no attributes provided from webview (sanity check) + (view as any).onDidReceiveMessage({ command: "update-attributes" }); + expect(updateAttributesMock).not.toHaveBeenCalled(); + + const attributes = { + owner: "owner", + group: "group", + perms: "-rwxrwxrwx", + }; + + // case 2: attributes provided from webview, pass owner/group as name + await (view as any).onDidReceiveMessage({ + command: "update-attributes", + attrs: attributes, + }); + expect(updateAttributesMock).toHaveBeenCalled(); + expect(view.panel.webview.postMessage).toHaveBeenCalledWith({ + updated: true, + }); + + // case 2: attributes provided from webview, pass owner/group as IDs + await (view as any).onDidReceiveMessage({ + command: "update-attributes", + attrs: { + ...attributes, + owner: "1", + group: "9001", + }, + }); + expect(updateAttributesMock).toHaveBeenCalled(); + expect(view.panel.webview.postMessage).toHaveBeenCalled(); + }); + + it("handles any errors while updating attributes", async () => { + // case 3: error thrown while updating attributes + updateAttributesMock.mockRejectedValueOnce(new Error("Failed to update attributes")); + await (view as any).onDidReceiveMessage({ + command: "update-attributes", + attrs: {}, + }); + expect(updateAttributesMock).toHaveBeenCalled(); + expect(view.panel.webview.postMessage).toHaveBeenCalledWith({ + updated: false, + }); + }); +}); diff --git a/packages/zowe-explorer/src/uss/AttributeView.ts b/packages/zowe-explorer/src/uss/AttributeView.ts new file mode 100644 index 0000000000..09445dd0d9 --- /dev/null +++ b/packages/zowe-explorer/src/uss/AttributeView.ts @@ -0,0 +1,113 @@ +import { FileAttributes, Gui, IZoweTree, IZoweUSSTreeNode, WebView, ZoweExplorerApi } from "@zowe/zowe-explorer-api"; +import { Disposable, ExtensionContext } from "vscode"; +import { ZoweExplorerApiRegister } from "../ZoweExplorerApiRegister"; + +export class AttributeView extends WebView { + private treeProvider: IZoweTree; + private readonly ussNode: IZoweUSSTreeNode; + private readonly ussApi: ZoweExplorerApi.IUss; + private readonly canUpdate: boolean; + + private onUpdateDisposable: Disposable; + + public constructor(context: ExtensionContext, treeProvider: IZoweTree, node: IZoweUSSTreeNode) { + const label = node?.label ? `Edit Attributes: ${node.label}` : "Edit Attributes"; + super(label, "edit-attributes", context, (message: object) => this.onDidReceiveMessage(message)); + this.treeProvider = treeProvider; + this.ussNode = node; + this.canUpdate = node.onUpdate != null; + this.ussApi = ZoweExplorerApiRegister.getUssApi(this.ussNode.getProfile()); + } + + protected async onDidReceiveMessage(message: any): Promise { + switch (message.command) { + case "refresh": + if (this.canUpdate) { + this.onUpdateDisposable = this.ussNode.onUpdate(async (node) => { + await this.panel.webview.postMessage({ + attributes: node.attributes, + name: node.fullPath, + readonly: this.ussApi.updateAttributes == null, + }); + this.onUpdateDisposable.dispose(); + }); + + if (this.ussNode.getParent()) { + this.treeProvider.refreshElement(this.ussNode.getParent()); + } else { + this.treeProvider.refresh(); + } + } + break; + case "ready": + await this.panel.webview.postMessage({ + attributes: this.ussNode.attributes, + name: this.ussNode.fullPath, + readonly: this.ussApi.updateAttributes == null, + }); + break; + case "update-attributes": + await this.updateAttributes(message); + break; + default: + break; + } + } + + private async updateAttributes(message: any): Promise { + if (!this.ussApi.updateAttributes) { + // The condition in this if statement should never be satisfied; the "Apply Changes" button is disabled + // when this API doesn't exist. But, this ensures the webview will be blocked from making update requests. + return; + } + + try { + if (Object.keys(message.attrs).length > 0) { + const attrs = message.attrs; + const newAttrs: Partial = {}; + if (!isNaN(parseInt(attrs.owner))) { + const uid = parseInt(attrs.owner); + newAttrs.uid = uid; + + // set owner to the UID to prevent mismatched UIDs/owners + newAttrs.owner = attrs.owner; + } else if (this.ussNode.attributes.owner !== attrs.owner) { + newAttrs.owner = attrs.owner; + } + + if (!isNaN(parseInt(attrs.group))) { + const gid = parseInt(attrs.group); + // must provide owner when changing group + newAttrs.owner = attrs.owner; + newAttrs.gid = gid; + + // set group to the GID to prevent mismatched GIDs/groups + newAttrs.group = attrs.group; + } else if (this.ussNode.attributes.group !== attrs.group) { + // must provide owner when changing group + newAttrs.owner = attrs.owner; + newAttrs.group = attrs.group; + } + + if (this.ussNode.attributes.perms !== attrs.perms) { + newAttrs.perms = attrs.perms; + } + + await this.ussApi.updateAttributes(this.ussNode.fullPath, newAttrs); + this.ussNode.attributes = { ...(this.ussNode.attributes ?? {}), ...newAttrs } as FileAttributes; + + await this.panel.webview.postMessage({ + updated: true, + }); + await Gui.infoMessage(`Updated file attributes for ${this.ussNode.fullPath}`); + } + } catch (err) { + await this.panel.webview.postMessage({ + updated: false, + }); + if (err instanceof Error) { + await Gui.errorMessage(`Failed to set file attributes for ${this.ussNode.fullPath}: ${err.toString()}`); + } + } + } +} diff --git a/packages/zowe-explorer/src/uss/actions.ts b/packages/zowe-explorer/src/uss/actions.ts index 6078d9ff95..96f05cb711 100644 --- a/packages/zowe-explorer/src/uss/actions.ts +++ b/packages/zowe-explorer/src/uss/actions.ts @@ -28,6 +28,7 @@ import { IUploadOptions } from "@zowe/zos-files-for-zowe-sdk"; import { fileExistsCaseSensitveSync } from "./utils"; import { UssFileTree, UssFileType } from "./FileStructure"; import { ZoweLogger } from "../utils/LoggerUtils"; +import { AttributeView } from "./AttributeView"; // Set up localization nls.config({ @@ -212,94 +213,8 @@ export async function uploadFile(node: IZoweUSSTreeNode, doc: vscode.TextDocumen } } -export function editAttributes(context: vscode.ExtensionContext, fileProvider: IZoweTree, node: IZoweUSSTreeNode): void { - const webviewLabel = node.label ? `Edit Attributes: ${node.label as string}` : "Edit Attributes"; - let treeDataDisposable: vscode.Disposable; - const editView = new WebView(webviewLabel, "edit-attributes", context, async (message: any) => { - const ussApi = ZoweExplorerApiRegister.getUssApi(node.getProfile()); - switch (message.command) { - case "refresh": - if (node.onUpdate != null) { - treeDataDisposable = node.onUpdate(async (n) => { - await editView.panel.webview.postMessage({ - attributes: n.attributes, - name: n.fullPath, - readonly: ussApi.updateAttributes == null, - }); - treeDataDisposable.dispose(); - }); - if (node.getParent()) { - fileProvider.refreshElement(node.getParent()); - } else { - fileProvider.refresh(); - } - } - break; - case "ready": - await editView.panel.webview.postMessage({ - attributes: node.attributes, - name: node.fullPath, - readonly: ussApi.updateAttributes == null, - }); - break; - case "update-attributes": - if (!ussApi.updateAttributes) { - // this condition should not be satisfied, as the button is disabled when this API doesn't exist. - // but, this ensures the webview cannot make an update request if the API is not implemented. - return; - } - - try { - if (Object.keys(message.attrs).length > 0) { - const attrs = message.attrs; - const newAttrs: Partial = {}; - if (!isNaN(parseInt(attrs.owner))) { - const uid = parseInt(attrs.owner); - newAttrs.uid = uid; - - // set owner to the UID to prevent mismatched UIDs/owners - newAttrs.owner = attrs.owner; - } else if (node.attributes.owner !== attrs.owner) { - newAttrs.owner = attrs.owner; - } - if (!isNaN(parseInt(attrs.group))) { - const gid = parseInt(attrs.group); - // must provide owner when changing group - newAttrs.owner = attrs.owner; - newAttrs.gid = gid; - - // set group to the GID to prevent mismatched GIDs/groups - newAttrs.group = attrs.group; - } else if (node.attributes.group !== attrs.group) { - // must provide owner when changing group - newAttrs.owner = attrs.owner; - newAttrs.group = attrs.group; - } - if (node.attributes.perms !== attrs.perms) { - newAttrs.perms = attrs.perms; - } - - await ussApi.updateAttributes(node.fullPath, newAttrs); - node.attributes = { ...(node.attributes ?? {}), ...newAttrs } as FileAttributes; - - await editView.panel.webview.postMessage({ - updated: true, - }); - await Gui.infoMessage(`Updated file attributes for ${node.fullPath}`); - } - } catch (err) { - await editView.panel.webview.postMessage({ - updated: false, - }); - if (err instanceof Error) { - await Gui.errorMessage(`Failed to set file attributes for ${node.fullPath}: ${err.toString()}`); - } - } - break; - default: - break; - } - }); +export function editAttributes(context: vscode.ExtensionContext, fileProvider: IZoweTree, node: IZoweUSSTreeNode): AttributeView { + return new AttributeView(context, fileProvider, node); } /**