From 15d0f11748f9317464911dd59e25d3433ba4b9a1 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Wed, 1 Nov 2023 13:23:22 -0400 Subject: [PATCH] feat: add upload/download for sas content (#550) **Summary** This makes a few changes around uploading/downloading files in the sas extension: - This adds an `Upload` context menu item to SAS content: This feature allows a user to upload one or more files to sas content - This adds a `Download` context menu item to SAS content: This allows you to download one or more files or folders, and specify where to save them - **NOTE**: This doesn't allow you to specify file name when you are specifying a single file to download. This is to make it such that we have consistency between downloading a single file or multiple files. The single file will be persisted with the same name from SAS content - This adds a `Download` context menu item to the results pane. Clicking this will allow the user to save the results as an html file - This introduces `ResultsPanelSubscriptionProvider` which is responsible for handling commands against the results panel **Testing** - [x] Tested uploading single and multiple files - [x] Tested downloading single files, files + folder, files + folder + a subset of children in the folder - [x] Tested downloading results (tested w/ tables & sgplots) --- CHANGELOG.md | 7 ++ client/src/commands/run.ts | 2 +- .../ContentNavigator/ContentDataProvider.ts | 102 +++++++++++++++--- .../ContentNavigator/ContentModel.ts | 2 - .../src/components/ContentNavigator/const.ts | 1 + .../src/components/ContentNavigator/index.ts | 89 ++++++++++++--- .../src/components/ContentNavigator/utils.ts | 4 + .../{ => ResultPanel}/ResultPanel.ts | 30 +++++- client/src/components/ResultPanel/index.ts | 36 +++++++ client/src/components/tasks/SasTasks.ts | 2 +- client/src/node/extension.ts | 3 + package.json | 71 ++++++++++++ package.nls.json | 11 +- 13 files changed, 323 insertions(+), 37 deletions(-) rename client/src/components/{ => ResultPanel}/ResultPanel.ts (55%) create mode 100644 client/src/components/ResultPanel/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d10c705c..2b1f0f7b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). If you introduce breaking changes, please group them together in the "Changed" section using the **BREAKING:** prefix. +## [Unreleased] + +### Added + +- Added the ability to upload and download sas content using the context menu ([#547](https://github.com/sassoftware/vscode-sas-extension/issues/547)) +- Added the ability to download results as an html file ([#546](https://github.com/sassoftware/vscode-sas-extension/issues/546)) + ## [v1.5.0] - 2023-10-27 ### Added diff --git a/client/src/commands/run.ts b/client/src/commands/run.ts index 49b60d6f7..8c8dd8cf0 100644 --- a/client/src/commands/run.ts +++ b/client/src/commands/run.ts @@ -13,7 +13,7 @@ import { import type { BaseLanguageClient } from "vscode-languageclient"; import { LogFn as LogChannelFn } from "../components/LogChannel"; -import { showResult } from "../components/ResultPanel"; +import { showResult } from "../components/ResultPanel/ResultPanel"; import { assign_SASProgramFile, wrapCodeWithOutputHtml, diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index cf6d0f870..6a013ebde 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -30,9 +30,10 @@ import { l10n, languages, window, + workspace, } from "vscode"; -import { lstat, readFile, readdir } from "fs"; +import { lstat, lstatSync, readFile, readdir } from "fs"; import { basename, join } from "path"; import { promisify } from "util"; @@ -60,6 +61,7 @@ import { getResourceIdFromItem, getTypeName, getUri, + isContainer, isItemInRecycleBin, isReference, resourceType, @@ -492,6 +494,77 @@ class ContentDataProvider }); } + public async uploadUrisToTarget( + uris: Uri[], + target: ContentItem, + ): Promise { + const failedUploads = []; + for (let i = 0; i < uris.length; ++i) { + const uri = uris[i]; + const fileName = basename(uri.fsPath); + if (lstatSync(uri.fsPath).isDirectory()) { + const success = await this.handleFolderDrop(target, uri.fsPath, false); + !success && failedUploads.push(fileName); + } else { + const file = await workspace.fs.readFile(uri); + const newUri = await this.createFile(target, fileName, file); + !newUri && failedUploads.push(fileName); + } + } + + if (failedUploads.length > 0) { + this.handleCreationResponse( + target, + undefined, + l10n.t(Messages.FileUploadError), + ); + } + } + + public async downloadContentItems( + folderUri: Uri, + selections: ContentItem[], + allSelections: readonly ContentItem[], + ): Promise { + for (let i = 0; i < selections.length; ++i) { + const selection = selections[i]; + if (isContainer(selection)) { + const newFolderUri = Uri.joinPath(folderUri, selection.name); + const selectionsWithinFolder = await this.childrenSelections( + selection, + allSelections, + ); + await workspace.fs.createDirectory(newFolderUri); + await this.downloadContentItems( + newFolderUri, + selectionsWithinFolder, + allSelections, + ); + } else { + await workspace.fs.writeFile( + Uri.joinPath(folderUri, selection.name), + await this.readFile(getUri(selection)), + ); + } + } + } + + private async childrenSelections( + selection: ContentItem, + allSelections: readonly ContentItem[], + ): Promise { + const foundSelections = allSelections.filter( + (foundSelection) => foundSelection.parentFolderUri === selection.uri, + ); + if (foundSelections.length > 0) { + return foundSelections; + } + + // If we don't have any child selections, then the folder must have been + // closed and therefore, we expect to select _all_ children + return this.getChildren(selection); + } + private async handleContentItemDrop( target: ContentItem, item: ContentItem, @@ -518,7 +591,7 @@ class ContentDataProvider } if (!success) { - await window.showErrorMessage( + window.showErrorMessage( l10n.t(message, { name: item.name, }), @@ -529,15 +602,17 @@ class ContentDataProvider private async handleFolderDrop( target: ContentItem, path: string, + displayErrorMessages: boolean = true, ): Promise { const folder = await this.model.createFolder(target, basename(path)); let success = true; if (!folder) { - await window.showErrorMessage( - l10n.t(Messages.FileDropError, { - name: basename(path), - }), - ); + displayErrorMessages && + window.showErrorMessage( + l10n.t(Messages.FileDropError, { + name: basename(path), + }), + ); return false; } @@ -561,11 +636,12 @@ class ContentDataProvider ); if (!fileCreated) { success = false; - await window.showErrorMessage( - l10n.t(Messages.FileDropError, { - name, - }), - ); + displayErrorMessages && + window.showErrorMessage( + l10n.t(Messages.FileDropError, { + name, + }), + ); } } }), @@ -604,7 +680,7 @@ class ContentDataProvider ); if (!fileCreated) { - await window.showErrorMessage( + window.showErrorMessage( l10n.t(Messages.FileDropError, { name, }), diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 3e2074524..d6f2e3907 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -241,7 +241,6 @@ export class ContentModel { } const fileLink: Link | null = getLink(createdResource.links, "GET", "self"); - const memberAdded = await this.addMember( fileLink?.uri, getLink(item.links, "POST", "addMember")?.uri, @@ -250,7 +249,6 @@ export class ContentModel { contentType, }, ); - if (!memberAdded) { return; } diff --git a/client/src/components/ContentNavigator/const.ts b/client/src/components/ContentNavigator/const.ts index 4c2c9abdd..4d5d13630 100644 --- a/client/src/components/ContentNavigator/const.ts +++ b/client/src/components/ContentNavigator/const.ts @@ -62,6 +62,7 @@ export const Messages = { FileDropError: l10n.t('Unable to drop item "{name}".'), FileOpenError: l10n.t("The file type is unsupported."), FileRestoreError: l10n.t("Unable to restore file."), + FileUploadError: l10n.t("Unable to upload files."), FileValidationError: l10n.t("Invalid file name."), FolderDeletionError: l10n.t("Unable to delete folder."), FolderRestoreError: l10n.t("Unable to restore folder."), diff --git a/client/src/components/ContentNavigator/index.ts b/client/src/components/ContentNavigator/index.ts index 71c0a97e5..2ebcbabb7 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -4,6 +4,7 @@ import { ConfigurationChangeEvent, Disposable, ExtensionContext, + OpenDialogOptions, ProgressLocation, Uri, commands, @@ -305,6 +306,56 @@ class ContentNavigator implements SubscriptionProvider { ); }, ), + commands.registerCommand( + "SAS.downloadResource", + async (resource: ContentItem) => { + const selections = this.treeViewSelections(resource); + const uris = await window.showOpenDialog({ + title: l10n.t("Choose where to save your files."), + openLabel: l10n.t("Save"), + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + }); + const uri = uris && uris.length > 0 ? uris[0] : undefined; + + if (!uri) { + return; + } + + await window.withProgress( + { + location: ProgressLocation.Notification, + title: l10n.t("Downloading files..."), + }, + async () => { + await this.contentDataProvider.downloadContentItems( + uri, + selections, + this.contentDataProvider.treeView.selection, + ); + }, + ); + }, + ), + // Below, we have three commands to upload files. Mac is currently the only + // platform that supports uploading both files and folders. So, for any platform + // that isn't Mac, we list a distinct upload file(s) or upload folder(s) command. + // See the `OpenDialogOptions` interface for more information. + commands.registerCommand( + "SAS.uploadResource", + async (resource: ContentItem) => this.uploadResource(resource), + ), + commands.registerCommand( + "SAS.uploadFileResource", + async (resource: ContentItem) => + this.uploadResource(resource, { canSelectFolders: false }), + ), + commands.registerCommand( + "SAS.uploadFolderResource", + async (resource: ContentItem) => + this.uploadResource(resource, { canSelectFiles: false }), + ), workspace.onDidChangeConfiguration( async (event: ConfigurationChangeEvent) => { if (event.affectsConfiguration("SAS.connectionProfiles")) { @@ -318,6 +369,31 @@ class ContentNavigator implements SubscriptionProvider { ]; } + private async uploadResource( + resource: ContentItem, + openDialogOptions: Partial = {}, + ) { + const uris: Uri[] = await window.showOpenDialog({ + canSelectFolders: true, + canSelectMany: true, + canSelectFiles: true, + ...openDialogOptions, + }); + if (!uris) { + return; + } + + await window.withProgress( + { + location: ProgressLocation.Notification, + title: l10n.t("Uploading files..."), + }, + async () => { + await this.contentDataProvider.uploadUrisToTarget(uris, resource); + }, + ); + } + private viyaEndpoint(): string { const activeProfile = profileConfig.getProfileByName( profileConfig.getActiveProfile(), @@ -329,19 +405,6 @@ class ContentNavigator implements SubscriptionProvider { : ""; } - private async handleCreationResponse( - resource: ContentItem, - newUri: Uri | undefined, - errorMessage: string, - ): Promise { - if (!newUri) { - window.showErrorMessage(errorMessage); - return; - } - - this.contentDataProvider.reveal(resource); - } - private treeViewSelections(item: ContentItem): ContentItem[] { const items = this.contentDataProvider.treeView.selection.length > 1 diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index 1a8ddb397..8299f93bc 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -81,6 +81,10 @@ export const resourceType = (item: ContentItem): string | undefined => { actions.push("convertNotebookToFlow"); } + if (!isContainer(item)) { + actions.push("allowDownload"); + } + if (actions.length === 0) { return; } diff --git a/client/src/components/ResultPanel.ts b/client/src/components/ResultPanel/ResultPanel.ts similarity index 55% rename from client/src/components/ResultPanel.ts rename to client/src/components/ResultPanel/ResultPanel.ts index 02d23ce3e..31d3a459c 100644 --- a/client/src/components/ResultPanel.ts +++ b/client/src/components/ResultPanel/ResultPanel.ts @@ -2,11 +2,28 @@ // SPDX-License-Identifier: Apache-2.0 import { Uri, ViewColumn, WebviewPanel, l10n, window } from "vscode"; -import { isSideResultEnabled, isSinglePanelEnabled } from "./utils/settings"; +import { v4 } from "uuid"; + +import { isSideResultEnabled, isSinglePanelEnabled } from "../utils/settings"; let resultPanel: WebviewPanel | undefined; +export const resultPanels: Record = {}; export const showResult = (html: string, uri?: Uri, title?: string) => { + html = html + // Inject vscode context into our results html body + .replace( + "", + "", + ); const sideResult = isSideResultEnabled(); const singlePanel = isSinglePanelEnabled(); if (!title) { @@ -14,8 +31,9 @@ export const showResult = (html: string, uri?: Uri, title?: string) => { } if (!singlePanel || !resultPanel) { + const resultPanelId = `SASResultPanel-${v4()}`; resultPanel = window.createWebviewPanel( - "SASSession", // Identifies the type of the webview. Used internally + resultPanelId, // Identifies the type of the webview. Used internally title, // Title of the panel displayed to the user { preserveFocus: true, @@ -23,7 +41,13 @@ export const showResult = (html: string, uri?: Uri, title?: string) => { }, // Editor column to show the new webview panel in. {}, // Webview options. More on these later. ); - resultPanel.onDidDispose(() => (resultPanel = undefined)); + resultPanel.onDidDispose( + ((id) => () => { + delete resultPanels[id]; + resultPanel = undefined; + })(resultPanelId), + ); + resultPanels[resultPanelId] = resultPanel; } else { const editor = uri ? window.visibleTextEditors.find( diff --git a/client/src/components/ResultPanel/index.ts b/client/src/components/ResultPanel/index.ts new file mode 100644 index 000000000..9db4d6681 --- /dev/null +++ b/client/src/components/ResultPanel/index.ts @@ -0,0 +1,36 @@ +// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Disposable, Uri, commands, window, workspace } from "vscode"; + +import { SubscriptionProvider } from "../SubscriptionProvider"; +import { resultPanels } from "./ResultPanel"; + +export class ResultPanelSubscriptionProvider implements SubscriptionProvider { + getSubscriptions(): Disposable[] { + return [ + commands.registerCommand( + "SAS.saveHTML", + async (context: { webview: string }) => { + const panel = resultPanels[context.webview] || undefined; + if (!panel) { + return; + } + const uri = await window.showSaveDialog({ + defaultUri: Uri.file(`results.html`), + }); + + if (!uri) { + return; + } + + await workspace.fs.writeFile( + uri, + new TextEncoder().encode(panel.webview.html), + ); + }, + ), + ]; + } +} + +export default ResultPanelSubscriptionProvider; diff --git a/client/src/components/tasks/SasTasks.ts b/client/src/components/tasks/SasTasks.ts index e72b62a2d..1860d11a3 100644 --- a/client/src/components/tasks/SasTasks.ts +++ b/client/src/components/tasks/SasTasks.ts @@ -4,7 +4,7 @@ import { EventEmitter, TaskDefinition, l10n } from "vscode"; import { runTask } from "../../commands/run"; import { RunResult } from "../../connection"; -import { showResult } from "../ResultPanel"; +import { showResult } from "../ResultPanel/ResultPanel"; import { getSASCodeFromActiveEditor, getSASCodeFromFile, diff --git a/client/src/node/extension.ts b/client/src/node/extension.ts index 924ae9713..3783fd084 100644 --- a/client/src/node/extension.ts +++ b/client/src/node/extension.ts @@ -42,6 +42,7 @@ import ContentNavigator from "../components/ContentNavigator"; import { setContext } from "../components/ExtensionContext"; import LibraryNavigator from "../components/LibraryNavigator"; import { LogTokensProvider, legend } from "../components/LogViewer"; +import ResultPanelSubscriptionProvider from "../components/ResultPanel"; import { NotebookController } from "../components/notebook/Controller"; import { NotebookSerializer } from "../components/notebook/Serializer"; import { ConnectionType } from "../components/profile"; @@ -100,6 +101,7 @@ export function activate(context: ExtensionContext): void { const libraryNavigator = new LibraryNavigator(context); const contentNavigator = new ContentNavigator(context); + const resultPanelSubscriptionProvider = new ResultPanelSubscriptionProvider(); context.subscriptions.push( commands.registerCommand("SAS.run", async () => { @@ -137,6 +139,7 @@ export function activate(context: ExtensionContext): void { activeProfileStatusBarIcon, ...libraryNavigator.getSubscriptions(), ...contentNavigator.getSubscriptions(), + ...resultPanelSubscriptionProvider.getSubscriptions(), // If configFile setting is changed, update watcher to watch new configuration file workspace.onDidChangeConfiguration((event: ConfigurationChangeEvent) => { if (event.affectsConfiguration("SAS.connectionProfiles")) { diff --git a/package.json b/package.json index b985384c2..31f07a142 100644 --- a/package.json +++ b/package.json @@ -577,6 +577,31 @@ "shortTitle": "%commands.SAS.notebook.new.short%", "title": "%commands.SAS.notebook.new%", "category": "SAS Notebook" + }, + { + "command": "SAS.downloadResource", + "title": "%commands.SAS.download%", + "category": "SAS" + }, + { + "command": "SAS.uploadResource", + "title": "%commands.SAS.upload%", + "category": "SAS" + }, + { + "command": "SAS.uploadFileResource", + "title": "%commands.SAS.uploadFiles%", + "category": "SAS" + }, + { + "command": "SAS.uploadFolderResource", + "title": "%commands.SAS.uploadFolders%", + "category": "SAS" + }, + { + "command": "SAS.saveHTML", + "title": "%commands.SAS.download%", + "category": "SAS" } ], "keybindings": [ @@ -592,6 +617,12 @@ } ], "menus": { + "webview/context": [ + { + "command": "SAS.saveHTML", + "when": "webviewId =~ /SASResultPanel-/" + } + ], "view/title": [ { "command": "SAS.refreshContent", @@ -676,6 +707,26 @@ "command": "SAS.emptyRecycleBin", "when": "viewItem =~ /empty/ && view == contentdataprovider", "group": "emptygroup@0" + }, + { + "command": "SAS.downloadResource", + "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == contentdataprovider", + "group": "uploaddownloadgroup@0" + }, + { + "command": "SAS.uploadResource", + "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == contentdataprovider && !listMultiSelection && workspacePlatform == mac", + "group": "uploaddownloadgroup@1" + }, + { + "command": "SAS.uploadFileResource", + "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == contentdataprovider && !listMultiSelection && workspacePlatform != mac", + "group": "uploaddownloadgroup@1" + }, + { + "command": "SAS.uploadFolderResource", + "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == contentdataprovider && !listMultiSelection && workspacePlatform != mac", + "group": "uploaddownloadgroup@1" } ], "editor/title/run": [ @@ -776,6 +827,26 @@ { "when": "false", "command": "SAS.downloadTable" + }, + { + "when": "false", + "command": "SAS.downloadResource" + }, + { + "when": "false", + "command": "SAS.uploadResource" + }, + { + "when": "false", + "command": "SAS.uploadFileResource" + }, + { + "when": "false", + "command": "SAS.uploadFolderResource" + }, + { + "when": "false", + "command": "SAS.saveHTML" } ], "file/newFile": [ diff --git a/package.nls.json b/package.nls.json index cbbfa29d0..0bca59a64 100644 --- a/package.nls.json +++ b/package.nls.json @@ -6,6 +6,7 @@ "commands.SAS.authorize": "Sign in", "commands.SAS.close": "Close Current Session", "commands.SAS.collapseAll": "Collapse All", + "commands.SAS.convertNotebookToFlow": "Convert to Flow...", "commands.SAS.deleteProfile": "Delete Connection Profile", "commands.SAS.deleteResource": "Delete", "commands.SAS.deleteTable": "Delete", @@ -22,7 +23,9 @@ "commands.SAS.runSelected": "Run Selected or All SAS Code", "commands.SAS.switchProfile": "Switch Current Connection Profile", "commands.SAS.updateProfile": "Update Connection Profile", - "commands.SAS.convertNotebookToFlow": "Convert to Flow...", + "commands.SAS.upload": "Upload", + "commands.SAS.uploadFiles": "Upload File(s)", + "commands.SAS.uploadFolders": "Upload Folder(s)", "configuration.SAS.connectionProfiles": "Define the connection profiles to connect to SAS servers. If you define more than one profile, you can switch between them.", "configuration.SAS.connectionProfiles.activeProfile": "Active SAS Connection Profile", "configuration.SAS.connectionProfiles.profiles": "SAS Connection Profiles", @@ -44,6 +47,9 @@ "configuration.SAS.connectionProfiles.profiles.ssh.username": "SAS SSH Connection username", "configuration.SAS.connectionProfiles.profiles.tokenFile": "SAS Viya Token File", "configuration.SAS.connectionProfiles.profiles.username": "SAS Viya User ID", + "configuration.SAS.flowConversionMode": "Choose the conversion mode for notebooks", + "configuration.SAS.flowConversionModeNode": "Convert each notebook cell to a node", + "configuration.SAS.flowConversionModeSwimlane": "Convert each notebook cell to a swimlane", "configuration.SAS.results.html.enabled": "Enable/disable ODS HTML5 output", "configuration.SAS.results.html.style": "Specifies the style for ODS HTML5 results.", "configuration.SAS.results.html.style.(auto)": "Let the extension pick a style that most closely matches the color theme.", @@ -51,9 +57,6 @@ "configuration.SAS.results.sideBySide": "Display results to the side of the code", "configuration.SAS.results.singlePanel": "Reuse single panel to display results", "configuration.SAS.userProvidedCertificates": "Provide trusted CA certificate files", - "configuration.SAS.flowConversionMode": "Choose the conversion mode for notebooks", - "configuration.SAS.flowConversionModeNode": "Convert each notebook cell to a node", - "configuration.SAS.flowConversionModeSwimlane": "Convert each notebook cell to a swimlane", "notebooks.SAS.htmlRenderer": "SAS HTML Renderer", "notebooks.SAS.logRenderer": "SAS Log Renderer", "notebooks.SAS.sasNotebook": "SAS Notebook",