Skip to content

Commit

Permalink
feat: add upload/download for sas content (#550)
Browse files Browse the repository at this point in the history
**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)
  • Loading branch information
scottdover authored Nov 1, 2023
1 parent 6650015 commit 15d0f11
Show file tree
Hide file tree
Showing 13 changed files with 323 additions and 37 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion client/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
102 changes: 89 additions & 13 deletions client/src/components/ContentNavigator/ContentDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -60,6 +61,7 @@ import {
getResourceIdFromItem,
getTypeName,
getUri,
isContainer,
isItemInRecycleBin,
isReference,
resourceType,
Expand Down Expand Up @@ -492,6 +494,77 @@ class ContentDataProvider
});
}

public async uploadUrisToTarget(
uris: Uri[],
target: ContentItem,
): Promise<void> {
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<void> {
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<ContentItem[]> {
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,
Expand All @@ -518,7 +591,7 @@ class ContentDataProvider
}

if (!success) {
await window.showErrorMessage(
window.showErrorMessage(
l10n.t(message, {
name: item.name,
}),
Expand All @@ -529,15 +602,17 @@ class ContentDataProvider
private async handleFolderDrop(
target: ContentItem,
path: string,
displayErrorMessages: boolean = true,
): Promise<boolean> {
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;
}
Expand All @@ -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,
}),
);
}
}
}),
Expand Down Expand Up @@ -604,7 +680,7 @@ class ContentDataProvider
);

if (!fileCreated) {
await window.showErrorMessage(
window.showErrorMessage(
l10n.t(Messages.FileDropError, {
name,
}),
Expand Down
2 changes: 0 additions & 2 deletions client/src/components/ContentNavigator/ContentModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -250,7 +249,6 @@ export class ContentModel {
contentType,
},
);

if (!memberAdded) {
return;
}
Expand Down
1 change: 1 addition & 0 deletions client/src/components/ContentNavigator/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down
89 changes: 76 additions & 13 deletions client/src/components/ContentNavigator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ConfigurationChangeEvent,
Disposable,
ExtensionContext,
OpenDialogOptions,
ProgressLocation,
Uri,
commands,
Expand Down Expand Up @@ -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")) {
Expand All @@ -318,6 +369,31 @@ class ContentNavigator implements SubscriptionProvider {
];
}

private async uploadResource(
resource: ContentItem,
openDialogOptions: Partial<OpenDialogOptions> = {},
) {
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(),
Expand All @@ -329,19 +405,6 @@ class ContentNavigator implements SubscriptionProvider {
: "";
}

private async handleCreationResponse(
resource: ContentItem,
newUri: Uri | undefined,
errorMessage: string,
): Promise<void> {
if (!newUri) {
window.showErrorMessage(errorMessage);
return;
}

this.contentDataProvider.reveal(resource);
}

private treeViewSelections(item: ContentItem): ContentItem[] {
const items =
this.contentDataProvider.treeView.selection.length > 1
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/ContentNavigator/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,52 @@
// 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<string, WebviewPanel> = {};

export const showResult = (html: string, uri?: Uri, title?: string) => {
html = html
// Inject vscode context into our results html body
.replace(
"<body ",
`<body data-vscode-context='${JSON.stringify({
preventDefaultContextMenuItems: true,
})}' `,
)
// Make sure the html and body take up the full height of the parent
// iframe so that the context menu is clickable anywhere on the page
.replace(
"</head>",
"<style>html,body { height: 100% !important; }</style></head>",
);
const sideResult = isSideResultEnabled();
const singlePanel = isSinglePanelEnabled();
if (!title) {
title = l10n.t("Result");
}

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,
viewColumn: sideResult ? ViewColumn.Beside : ViewColumn.Active,
}, // 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(
Expand Down
Loading

0 comments on commit 15d0f11

Please sign in to comment.