Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add upload/download for sas content #550

Merged
merged 16 commits into from
Nov 1, 2023
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