From 74d93adc57e70c1985de55bcffae55546d793324 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Thu, 21 Nov 2024 09:46:19 -0500 Subject: [PATCH] feat: implement sas file system for viya connections (#1203) **Summary** This adds sas file system support for viya connections. Notable changes include: - Introducing a `canRecycleResource` function to sas content adapter. This allows us to make a determination about whether or not we should show a dialog for deleted files. Since SAS file system doesn't support recycle bin, we show the deletion message every time a file is deleted (since it's a permanent deletion) - Creates a distinct connection for `NotebookConverter` for connecting to sas studio (instead of re-using content model's connection) - Favorites are not implemented in this pull request and will be implemented in a future PR **Testing** - [x] File/folder creation - [x] Create file/folder w/ context menu - [x] Create file/folder by upload - [x] Create file/folder by drag & drop (create multiple files) - [x] File/folder deletion - [x] Test file deletion with context menu - [x] Test multi-file deletion with context menu - [x] File/folder updates - [x] Test updating file/folder name - [x] Test updating file contents - [x] Test moving file/folder (multiple files/folders) - [x] Test downloading files/folders - [x] Make sure refresh works as expected - [x] Make sure connections are automatically refreshed after they become stale - [x] Make sure a sas notebook file can be converted to a flow (test with sas content as well) - [x] Make sure we're displaying all files/folders for sas server and that items are sorted by type (directories before files), then alphabetically - [x] Make sure we can collapse all folders **TODOs** - [x] Update CHANGELOG - [x] Update `matrix.md` with details about sas server --- CHANGELOG.md | 6 + .../ContentNavigator/ContentAdapterFactory.ts | 5 +- .../ContentNavigator/ContentDataProvider.ts | 148 ++++- .../ContentNavigator/ContentModel.ts | 63 +- .../src/components/ContentNavigator/const.ts | 50 +- .../components/ContentNavigator/convert.ts | 35 +- .../src/components/ContentNavigator/index.ts | 59 +- .../src/components/ContentNavigator/types.ts | 7 +- .../src/components/ContentNavigator/utils.ts | 49 +- .../connection/rest/RestSASServerAdapter.ts | 539 ++++++++++++++++++ .../src/connection/rest/SASContentAdapter.ts | 29 +- client/src/connection/rest/util.ts | 12 +- client/src/node/extension.ts | 36 +- .../ContentDataProvider.test.ts | 32 +- icons/dark/serverDark.svg | 8 + icons/dark/userWorkspaceDark.svg | 4 + icons/light/serverLight.svg | 8 + icons/light/userWorkspaceLight.svg | 4 + l10n/bundle.l10n.de.json | 1 - l10n/bundle.l10n.es.json | 1 - l10n/bundle.l10n.fr.json | 1 - l10n/bundle.l10n.it.json | 1 - l10n/bundle.l10n.ja.json | 1 - l10n/bundle.l10n.ko.json | 1 - l10n/bundle.l10n.pt-br.json | 1 - l10n/bundle.l10n.zh-cn.json | 1 - package.json | 201 ++++++- package.nls.de.json | 1 - package.nls.es.json | 1 - package.nls.fr.json | 1 - package.nls.it.json | 1 - package.nls.ja.json | 1 - package.nls.json | 3 +- package.nls.ko.json | 1 - package.nls.pt-br.json | 1 - package.nls.zh-cn.json | 1 - website/docs/Features/accessServer.md | 23 + website/docs/Features/sasCodeEditing.md | 2 +- website/docs/Features/sasNotebook.md | 4 + website/docs/matrix.md | 1 + 40 files changed, 1181 insertions(+), 163 deletions(-) create mode 100644 client/src/connection/rest/RestSASServerAdapter.ts create mode 100644 icons/dark/serverDark.svg create mode 100644 icons/dark/userWorkspaceDark.svg create mode 100644 icons/light/serverLight.svg create mode 100644 icons/light/userWorkspaceLight.svg create mode 100644 website/docs/Features/accessServer.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d29104a6..7f9627289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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 support for SAS server for viya connections ([#1203](https://github.com/sassoftware/vscode-sas-extension/pull/1203)) + ## [v1.11.0] - 2024-10-09 ### Added diff --git a/client/src/components/ContentNavigator/ContentAdapterFactory.ts b/client/src/components/ContentNavigator/ContentAdapterFactory.ts index 52bb3caad..d115067e9 100644 --- a/client/src/components/ContentNavigator/ContentAdapterFactory.ts +++ b/client/src/components/ContentNavigator/ContentAdapterFactory.ts @@ -1,5 +1,6 @@ // Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import RestSASServerAdapter from "../../connection/rest/RestSASServerAdapter"; import SASContentAdapter from "../../connection/rest/SASContentAdapter"; import { ConnectionType } from "../profile"; import { @@ -9,13 +10,15 @@ import { } from "./types"; class ContentAdapterFactory { - // TODO #889 Update this to return RestSASServerAdapter & ITCSASServerAdapter + // TODO #889 Update this to return ITCSASServerAdapter public create( connectionType: ConnectionType, sourceType: ContentNavigatorConfig["sourceType"], ): ContentAdapter { const key = `${connectionType}.${sourceType}`; switch (key) { + case `${ConnectionType.Rest}.${ContentSourceType.SASServer}`: + return new RestSASServerAdapter(); case `${ConnectionType.Rest}.${ContentSourceType.SASContent}`: default: return new SASContentAdapter(); diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index 9f2557e4d..f6c1afe97 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -14,7 +14,6 @@ import { FileType, Position, ProviderResult, - Tab, TabInputNotebook, TabInputText, TextDocument, @@ -45,6 +44,8 @@ import { FAVORITES_FOLDER_TYPE, Messages, ROOT_FOLDER_TYPE, + SERVER_HOME_FOLDER_TYPE, + SERVER_ROOT_FOLDER_TYPE, TRASH_FOLDER_TYPE, } from "./const"; import { @@ -52,7 +53,11 @@ import { ContentNavigatorConfig, FileManipulationEvent, } from "./types"; -import { getFileStatement, isContainer as getIsContainer } from "./utils"; +import { + getEditorTabsForItem, + getFileStatement, + isContainer as getIsContainer, +} from "./utils"; class ContentDataProvider implements @@ -68,7 +73,7 @@ class ContentDataProvider private _onDidChange: EventEmitter; private _treeView: TreeView; private _dropEditProvider: Disposable; - private readonly model: ContentModel; + private model: ContentModel; private extensionUri: Uri; private mimeType: string; @@ -114,6 +119,10 @@ class ContentDataProvider }); } + public useModel(contentModel: ContentModel) { + this.model = contentModel; + } + public async handleDrop( target: ContentItem, sources: DataTransfer, @@ -278,23 +287,68 @@ class ContentDataProvider name: string, ): Promise { const closing = closeFileIfOpen(item); - if (!(await closing)) { + const removedTabUris = await closing; + if (!removedTabUris) { return; } const newItem = await this.model.renameResource(item, name); - if (newItem) { - const newUri = newItem.vscUri; - if (closing !== true) { - // File was open before rename, so re-open it - commands.executeCommand("vscode.open", newUri); + if (!newItem) { + return; + } + + const newUri = newItem.vscUri; + const oldUriToNewUriMap = [[item.vscUri, newUri]]; + const newItemIsContainer = getIsContainer(newItem); + if (closing !== true && !newItemIsContainer) { + await commands.executeCommand("vscode.openWith", newUri, "default", { + preview: false, + }); + } + if (closing !== true && newItemIsContainer) { + const urisToOpen = getPreviouslyOpenedChildItems( + await this.getChildren(newItem), + ); + for (const [, newUri] of urisToOpen) { + await commands.executeCommand("vscode.openWith", newUri, "default", { + preview: false, + }); } + oldUriToNewUriMap.push(...urisToOpen); + } + oldUriToNewUriMap.forEach(([uri, newUri]) => this._onDidManipulateFile.fire({ type: "rename", - uri: item.vscUri, + uri, newUri, - }); - return newUri; + }), + ); + return newUri; + + function getPreviouslyOpenedChildItems(childItems: ContentItem[]) { + const loadChildItems = closing !== true && newItemIsContainer; + if (!Array.isArray(removedTabUris) || !loadChildItems) { + return []; + } + // Here's where things get a little weird. When we rename folders in + // sas content, we _don't_ close those files. It doesn't matter since + // their path isn't hierarchical. In sas file system, the path is hierarchical, + // thus we need to re-open all the closed files. This does that by getting + // children and comparing the removedTabUris + const filteredChildItems = childItems + .map((childItem) => { + const matchingUri = removedTabUris.find((uri) => + uri.path.endsWith(childItem.name), + ); + if (!matchingUri) { + return; + } + + return [matchingUri, childItem.vscUri]; + }) + .filter((exists) => exists); + + return filteredChildItems; } } @@ -314,6 +368,10 @@ class ContentDataProvider return success; } + public canRecycleResource(item: ContentItem): boolean { + return this.model.canRecycleResource(item); + } + public async recycleResource(item: ContentItem): Promise { if (!(await closeFileIfOpen(item))) { return false; @@ -495,13 +553,34 @@ class ContentDataProvider return this.getChildren(selection); } + private async moveItem( + item: ContentItem, + targetUri: string, + ): Promise { + if (!targetUri) { + return false; + } + + const closing = closeFileIfOpen(item); + if (!(await closing)) { + return false; + } + + const newUri = await this.model.moveTo(item, targetUri); + if (closing !== true) { + commands.executeCommand("vscode.open", newUri); + } + + return !!newUri; + } + private async handleContentItemDrop( target: ContentItem, item: ContentItem, ): Promise { let success = false; let message = Messages.FileDropError; - if (item.flags.isInRecycleBin) { + if (item.flags?.isInRecycleBin) { message = Messages.FileDragFromTrashError; } else if (item.isReference) { message = Messages.FileDragFromFavorites; @@ -511,10 +590,7 @@ class ContentDataProvider success = await this.addToMyFavorites(item); } else { const targetUri = target.resourceId; - if (targetUri) { - success = await this.model.moveTo(item, targetUri); - } - + success = await this.moveItem(item, targetUri); if (success) { this.refresh(); } @@ -637,6 +713,12 @@ class ContentDataProvider case FAVORITES_FOLDER_TYPE: icon = "favoritesFolder"; break; + case SERVER_HOME_FOLDER_TYPE: + icon = "userWorkspace"; + break; + case SERVER_ROOT_FOLDER_TYPE: + icon = "server"; + break; default: icon = "folder"; break; @@ -647,6 +729,7 @@ class ContentDataProvider icon = "sasProgramFile"; } } + return icon !== "" ? { dark: Uri.joinPath(this.extensionUri, `icons/dark/${icon}Dark.svg`), @@ -661,17 +744,26 @@ class ContentDataProvider export default ContentDataProvider; -const closeFileIfOpen = (item: ContentItem) => { - const fileUri = item.vscUri; - const tabs: Tab[] = window.tabGroups.all.map((tg) => tg.tabs).flat(); - const tab = tabs.find( - (tab) => - (tab.input instanceof TabInputText || - tab.input instanceof TabInputNotebook) && - tab.input.uri.query === fileUri.query, // compare the file id - ); - if (tab) { - return window.tabGroups.close(tab); +const closeFileIfOpen = (item: ContentItem): Promise | boolean => { + const tabs = getEditorTabsForItem(item); + if (tabs.length > 0) { + return new Promise((resolve, reject) => { + Promise.all(tabs.map((tab) => window.tabGroups.close(tab))) + .then(() => + resolve( + tabs + .map( + (tab) => + (tab.input instanceof TabInputText || + tab.input instanceof TabInputNotebook) && + tab.input.uri, + ) + .filter((exists) => exists), + ), + ) + .catch(reject); + }); } + return true; }; diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index d4dd9fc6b..47e9fa0bf 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -1,9 +1,12 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Uri } from "vscode"; +import { Uri, l10n } from "vscode"; -import { Messages, ROOT_FOLDERS } from "./const"; +import { extname } from "path"; + +import { ALL_ROOT_FOLDERS, Messages } from "./const"; import { ContentAdapter, ContentItem } from "./types"; +import { isItemInRecycleBin } from "./utils"; export class ContentModel { private contentAdapter: ContentAdapter; @@ -33,7 +36,8 @@ export class ContentModel { return Object.entries(await this.contentAdapter.getRootItems()) .sort( // sort the delegate folders as the order in the supportedDelegateFolders - (a, b) => ROOT_FOLDERS.indexOf(a[0]) - ROOT_FOLDERS.indexOf(b[0]), + (a, b) => + ALL_ROOT_FOLDERS.indexOf(a[0]) - ALL_ROOT_FOLDERS.indexOf(b[0]), ) .map((entry) => entry[1]); } @@ -90,6 +94,44 @@ export class ContentModel { ); } + public async createUniqueFileOfPrefix( + parentItem: ContentItem, + fileName: string, + buffer?: ArrayBufferLike, + ) { + const itemsInFolder = await this.getChildren(parentItem); + const uniqueFileName = getUniqueFileName(); + + return await this.createFile(parentItem, uniqueFileName, buffer); + + function getUniqueFileName(): string { + const ext = extname(fileName); + const basename = fileName.replace(ext, ""); + const usedFlowNames = itemsInFolder.reduce((carry, item) => { + if (item.name.endsWith(ext)) { + return { ...carry, [item.name]: true }; + } + return carry; + }, {}); + + if (!usedFlowNames[fileName]) { + return fileName; + } + + let number = 1; + let newFileName; + do { + newFileName = l10n.t("{basename}_Copy{number}{ext}", { + basename, + number: number++, + ext, + }); + } while (usedFlowNames[newFileName]); + + return newFileName || fileName; + } + } + public async createFolder( item: ContentItem, name: string, @@ -127,7 +169,7 @@ export class ContentModel { public async moveTo( item: ContentItem, targetParentFolderUri: string, - ): Promise { + ): Promise { return await this.contentAdapter.moveItem(item, targetParentFolderUri); } @@ -139,11 +181,20 @@ export class ContentModel { return await this.contentAdapter.getFolderPathForItem(contentItem); } + public canRecycleResource(item: ContentItem): boolean { + return ( + this.contentAdapter.recycleItem && + this.contentAdapter.restoreItem && + !isItemInRecycleBin(item) && + item.permission.write + ); + } + public async recycleResource(item: ContentItem) { - return await this.contentAdapter.recycleItem(item); + return await this.contentAdapter?.recycleItem(item); } public async restoreResource(item: ContentItem) { - return await this.contentAdapter.restoreItem(item); + return await this.contentAdapter?.restoreItem(item); } } diff --git a/client/src/components/ContentNavigator/const.ts b/client/src/components/ContentNavigator/const.ts index b03420e3a..7c6e6c752 100644 --- a/client/src/components/ContentNavigator/const.ts +++ b/client/src/components/ContentNavigator/const.ts @@ -2,32 +2,29 @@ // SPDX-License-Identifier: Apache-2.0 import { l10n } from "vscode"; +import { createStaticFolder } from "./utils"; + export const DEFAULT_FILE_CONTENT_TYPE = "text/plain"; const CONTENT_FOLDER_ID = "CONTENT_FOLDER_ID"; export const ROOT_FOLDER_TYPE = "RootFolder"; +export const ROOT_FOLDER = createStaticFolder( + CONTENT_FOLDER_ID, + "SAS Content", + ROOT_FOLDER_TYPE, + "/folders/folders", +); -export const ROOT_FOLDER = { - // actual root for service - id: CONTENT_FOLDER_ID, - name: "SAS Content", - type: ROOT_FOLDER_TYPE, - uri: CONTENT_FOLDER_ID, - links: [ - { - method: "GET", - rel: "members", - href: "/folders/folders", - uri: "/folders/folders", - }, - { - method: "GET", - rel: "self", - href: CONTENT_FOLDER_ID, - uri: CONTENT_FOLDER_ID, - }, - ], -}; +export const SERVER_FOLDER_ID = "SERVER_FOLDER_ID"; +export const SERVER_ROOT_FOLDER_TYPE = "ServerRootFolder"; +export const SERVER_HOME_FOLDER_TYPE = "ServerHomeFolder"; +export const SAS_SERVER_ROOT_FOLDER = createStaticFolder( + SERVER_FOLDER_ID, + "SAS Server", + SERVER_ROOT_FOLDER_TYPE, + "/", + "getDirectoryMembers", +); export const FILE_TYPE = "file"; export const DATAFLOW_TYPE = "dataFlow"; @@ -46,13 +43,20 @@ export const FOLDER_TYPES = [ TRASH_FOLDER_TYPE, ]; -export const ROOT_FOLDERS = [ +export const SAS_CONTENT_ROOT_FOLDERS = [ "@myFavorites", "@myFolder", "@sasRoot", "@myRecycleBin", ]; +export const SAS_SERVER_ROOT_FOLDERS = ["@sasServerRoot"]; + +export const ALL_ROOT_FOLDERS = [ + ...SAS_CONTENT_ROOT_FOLDERS, + ...SAS_SERVER_ROOT_FOLDERS, +]; + export const Messages = { AddFileToMyFolderFailure: l10n.t("Unable to add file to my folder."), AddFileToMyFolderSuccess: l10n.t("File added to my folder."), @@ -77,7 +81,7 @@ export const Messages = { FolderDeletionError: l10n.t("Unable to delete folder."), FolderRestoreError: l10n.t("Unable to restore folder."), FolderValidationError: l10n.t( - "The folder name cannot contain more than 100 characters.", + "The folder name cannot contain more than 100 characters or have invalid characters.", ), NewFileCreationError: l10n.t('Unable to create file "{name}".'), NewFilePrompt: l10n.t("Enter a file name."), diff --git a/client/src/components/ContentNavigator/convert.ts b/client/src/components/ContentNavigator/convert.ts index 8379ff721..4a8783e2a 100644 --- a/client/src/components/ContentNavigator/convert.ts +++ b/client/src/components/ContentNavigator/convert.ts @@ -1,18 +1,19 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Uri, l10n, workspace } from "vscode"; +import { Uri, authentication, l10n, workspace } from "vscode"; +import axios, { AxiosInstance } from "axios"; import { basename } from "path"; import { v4 } from "uuid"; -import SASContentAdapter from "../../connection/rest/SASContentAdapter"; import { associateFlowObject, createStudioSession, } from "../../connection/studio"; +import { SASAuthProvider } from "../AuthProvider"; import { ContentModel } from "./ContentModel"; import { MYFOLDER_TYPE, Messages } from "./const"; -import { ContentItem } from "./types"; +import { ContentItem, ContentSourceType } from "./types"; import { isContentItem } from "./utils"; const stepRef: Record = { @@ -339,10 +340,13 @@ export function convertNotebookToFlow( export class NotebookToFlowConverter { protected studioSessionId: string; + protected connection: AxiosInstance; + public constructor( protected readonly resource: ContentItem | Uri, protected readonly contentModel: ContentModel, protected readonly viyaEndpoint: string, + protected readonly sourceType: ContentSourceType, ) {} public get inputName() { @@ -351,13 +355,6 @@ export class NotebookToFlowConverter { : basename(this.resource.fsPath); } - private get connection() { - return ( - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - (this.contentModel.getAdapter() as SASContentAdapter).getConnection() - ); - } - private async parent() { const parentItem = isContentItem(this.resource) ? await this.contentModel.getParent(this.resource) @@ -385,9 +382,11 @@ export class NotebookToFlowConverter { } public async establishConnection() { - if (!this.contentModel.connected()) { - await this.contentModel.connect(this.viyaEndpoint); - } + this.connection = axios.create({ baseURL: this.viyaEndpoint }); + const session = await authentication.getSession(SASAuthProvider.id, [], { + createIfNone: true, + }); + this.connection.defaults.headers.common.Authorization = `Bearer ${session.accessToken}`; try { const result = await createStudioSession(this.connection); @@ -412,7 +411,7 @@ export class NotebookToFlowConverter { } const parentItem = await this.parent(); - const newItem = await this.contentModel.createFile( + const newItem = await this.contentModel.createUniqueFileOfPrefix( parentItem, outputName, flowDataUint8Array, @@ -423,6 +422,14 @@ export class NotebookToFlowConverter { ); } + // We don't need to associate the flow object if it's stored in sas server + if (this.sourceType === ContentSourceType.SASServer) { + return { + parentItem, + folderName: parentItem.uri.split("/").pop().replace(/~fs~/g, "/"), + }; + } + // associate the new .flw file with SAS Studio const folderName = await associateFlowObject( outputName, diff --git a/client/src/components/ContentNavigator/index.ts b/client/src/components/ContentNavigator/index.ts index 88980006a..4818f0545 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -29,7 +29,7 @@ import { ContentSourceType, FileManipulationEvent, } from "./types"; -import { isContainer as getIsContainer, isItemInRecycleBin } from "./utils"; +import { isContainer as getIsContainer } from "./utils"; const fileValidator = (value: string): string | null => /^([^/<>;\\{}?#]+)\.\w+$/.test( @@ -47,15 +47,29 @@ const flowFileValidator = (value: string): string | null => { return res; }; -const folderValidator = (value: string): string | null => - value.length <= 100 ? null : Messages.FolderValidationError; +const folderValidator = ( + value: string, + sourceType: ContentSourceType, +): string | null => { + const regex = + sourceType === ContentSourceType.SASServer + ? new RegExp(/[:/?\\*"|<>]/g) + : new RegExp(/[/;\\{}<>]/g); + + return value.length <= 100 && !regex.test(value) + ? null + : Messages.FolderValidationError; +}; class ContentNavigator implements SubscriptionProvider { private contentDataProvider: ContentDataProvider; private contentModel: ContentModel; private sourceType: ContentNavigatorConfig["sourceType"]; + private treeIdentifier: ContentNavigatorConfig["treeIdentifier"]; constructor(context: ExtensionContext, config: ContentNavigatorConfig) { + this.sourceType = config.sourceType; + this.treeIdentifier = config.treeIdentifier; this.contentModel = new ContentModel( this.contentAdapterForConnectionType(), ); @@ -64,7 +78,6 @@ class ContentNavigator implements SubscriptionProvider { context.extensionUri, config, ); - this.sourceType = config.sourceType; workspace.registerFileSystemProvider( config.sourceType, @@ -91,7 +104,7 @@ class ContentNavigator implements SubscriptionProvider { async (resource: ContentItem) => { const isContainer = getIsContainer(resource); const moveToRecycleBin = - !isItemInRecycleBin(resource) && resource.permission.write; + this.contentDataProvider.canRecycleResource(resource); if ( !moveToRecycleBin && !(await window.showWarningMessage( @@ -185,7 +198,8 @@ class ContentNavigator implements SubscriptionProvider { const folderName = await window.showInputBox({ prompt: Messages.NewFolderPrompt, title: Messages.NewFolderTitle, - validateInput: folderValidator, + validateInput: (folderName) => + folderValidator(folderName, this.sourceType), }); if (!folderName) { return; @@ -213,7 +227,9 @@ class ContentNavigator implements SubscriptionProvider { ? Messages.RenameFolderTitle : Messages.RenameFileTitle, value: resource.name, - validateInput: isContainer ? folderValidator : fileValidator, + validateInput: isContainer + ? (value) => folderValidator(value, this.sourceType) + : fileValidator, }); if (!name || name === resource.name) { return; @@ -267,18 +283,19 @@ class ContentNavigator implements SubscriptionProvider { ); }, ), - commands.registerCommand(`${SAS}.collapseAllContent`, () => { - commands.executeCommand( - "workbench.actions.treeView.contentdataprovider.collapseAll", - ); - }), + commands.registerCommand( + `${SAS}.collapseAllContent`, + this.collapseAllContent.bind(this), + ), commands.registerCommand( `${SAS}.convertNotebookToFlow`, async (resource: ContentItem | Uri) => { + await this.contentModel.connect(this.viyaEndpoint()); const notebookToFlowConverter = new NotebookToFlowConverter( resource, this.contentModel, this.viyaEndpoint(), + this.sourceType, ); const inputName = notebookToFlowConverter.inputName; @@ -383,8 +400,16 @@ class ContentNavigator implements SubscriptionProvider { async (event: ConfigurationChangeEvent) => { if (event.affectsConfiguration("SAS.connectionProfiles")) { const endpoint = this.viyaEndpoint(); + this.collapseAllContent(); + const contentModel = new ContentModel( + this.contentAdapterForConnectionType(), + ); + this.contentDataProvider.useModel(contentModel); + this.contentModel = contentModel; if (endpoint) { await this.contentDataProvider.connect(endpoint); + } else { + await this.contentDataProvider.refresh(); } } }, @@ -392,6 +417,16 @@ class ContentNavigator implements SubscriptionProvider { ]; } + private async collapseAllContent() { + const collapeAllCmd = `workbench.actions.treeView.${this.treeIdentifier}.collapseAll`; + const commandExists = (await commands.getCommands()).find( + (c) => c === collapeAllCmd, + ); + if (commandExists) { + commands.executeCommand(collapeAllCmd); + } + } + private async uploadResource( resource: ContentItem, openDialogOptions: Partial = {}, diff --git a/client/src/components/ContentNavigator/types.ts b/client/src/components/ContentNavigator/types.ts index f42c55852..15508f73f 100644 --- a/client/src/components/ContentNavigator/types.ts +++ b/client/src/components/ContentNavigator/types.ts @@ -81,7 +81,6 @@ export interface ContentAdapter { getContentOfItem: (item: ContentItem) => Promise; getContentOfUri: (uri: Uri) => Promise; getFolderPathForItem: (item: ContentItem) => Promise; - getItemOfId: (id: string) => Promise; getItemOfUri: (uri: Uri) => Promise; getParentOfItem: (item: ContentItem) => Promise; getRootFolder: (name: string) => ContentItem | undefined; @@ -90,14 +89,14 @@ export interface ContentAdapter { moveItem: ( item: ContentItem, targetParentFolderUri: string, - ) => Promise; - recycleItem: (item: ContentItem) => Promise<{ newUri?: Uri; oldUri?: Uri }>; + ) => Promise; + recycleItem?: (item: ContentItem) => Promise<{ newUri?: Uri; oldUri?: Uri }>; removeItemFromFavorites: (item: ContentItem) => Promise; renameItem: ( item: ContentItem, newName: string, ) => Promise; - restoreItem: (item: ContentItem) => Promise; + restoreItem?: (item: ContentItem) => Promise; updateContentOfItem(uri: Uri, content: string): Promise; } diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index 84afb107c..2defeec3d 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -1,6 +1,13 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { FileType, SnippetString } from "vscode"; +import { + FileType, + SnippetString, + Tab, + TabInputNotebook, + TabInputText, + window, +} from "vscode"; import { DEFAULT_FILE_CONTENT_TYPE } from "./const"; import mimeTypes from "./mime-types"; @@ -47,3 +54,43 @@ export const getFileStatement = ( export const getFileContentType = (fileName: string) => mimeTypes[fileName.split(".").pop().toLowerCase()] || DEFAULT_FILE_CONTENT_TYPE; + +export const createStaticFolder = ( + folderId: string, + name: string, + type: string, + membersUri: string, + membersRel: string = "members", +) => ({ + id: folderId, + name, + type: type, + uri: folderId, + links: [ + { + method: "GET", + rel: membersRel, + href: membersUri, + uri: membersUri, + type: "GET", + }, + { + method: "GET", + rel: "self", + href: folderId, + uri: folderId, + type: "GET", + }, + ], +}); + +export const getEditorTabsForItem = (item: ContentItem) => { + const fileUri = item.vscUri; + const tabs: Tab[] = window.tabGroups.all.map((tg) => tg.tabs).flat(); + return tabs.filter( + (tab) => + (tab.input instanceof TabInputText || + tab.input instanceof TabInputNotebook) && + tab.input.uri.query.includes(fileUri.query), // compare the file id + ); +}; diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts new file mode 100644 index 000000000..d07cfdbb3 --- /dev/null +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -0,0 +1,539 @@ +// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { FileType, Uri } from "vscode"; + +import { AxiosResponse } from "axios"; + +import { getSession } from ".."; +import { + FOLDER_TYPES, + SAS_SERVER_ROOT_FOLDER, + SAS_SERVER_ROOT_FOLDERS, + SERVER_FOLDER_ID, + SERVER_HOME_FOLDER_TYPE, +} from "../../components/ContentNavigator/const"; +import { + AddChildItemProperties, + ContentAdapter, + ContentItem, + RootFolderMap, +} from "../../components/ContentNavigator/types"; +import { + createStaticFolder, + isReference, +} from "../../components/ContentNavigator/utils"; +import { appendSessionLogFn } from "../../components/logViewer"; +import { FileProperties, FileSystemApi } from "./api/compute"; +import { getApiConfig } from "./common"; +import { + getLink, + getResourceId, + getResourceIdFromItem, + getSasServerUri, + getTypeName, + resourceType, +} from "./util"; + +const SAS_SERVER_HOME_DIRECTORY = "SAS_SERVER_HOME_DIRECTORY"; +const SAS_FILE_SEPARATOR = "~fs~"; + +class RestSASServerAdapter implements ContentAdapter { + protected baseUrl: string; + protected fileSystemApi: ReturnType; + protected sessionId: string; + private rootFolders: RootFolderMap; + private fileMetadataMap: { + [id: string]: { etag: string; lastModified?: string; contentType?: string }; + }; + + public constructor() { + this.rootFolders = {}; + this.fileMetadataMap = {}; + } + addChildItem: ( + childItemUri: string | undefined, + parentItemUri: string | undefined, + properties: AddChildItemProperties, + ) => Promise; + recycleItem?: (item: ContentItem) => Promise<{ newUri?: Uri; oldUri?: Uri }>; + restoreItem?: (item: ContentItem) => Promise; + + private async establishConnection() { + const session = getSession(); + session.onSessionLogFn = appendSessionLogFn; + await session.setup(true); + this.sessionId = session?.sessionId(); + + return this.sessionId; + } + + public async connect(): Promise { + await this.establishConnection(); + // This proxies all calls to the fileSystem api to reconnect + // if we ever get a 401 (unauthorized) + const reconnect = async () => { + return await this.establishConnection(); + }; + this.fileSystemApi = new Proxy(FileSystemApi(getApiConfig()), { + get: function (target, property) { + if (typeof target[property] === "function") { + return new Proxy(target[property], { + apply: async function (target, _this, argList) { + try { + return await target(...argList); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // If we get any error, lets reconnect and try again. If we fail a second time, + // then we can assume it's a "real" error + const sessionId = await reconnect(); + + // If we reconnected, lets make sure we update our session id + if (argList.length && argList[0].sessionId) { + argList[0].sessionId = sessionId; + } + + return await target(...argList); + } + }, + }); + } + + return target[property]; + }, + }); + } + + public connected(): boolean { + return true; + } + + public async setup(): Promise { + if (this.sessionId && this.fileSystemApi) { + return; + } + + await this.connect(); + } + + // TODO #417 Implement favorites + public async addItemToFavorites(): Promise { + throw new Error("Method not implemented."); + } + + // TODO #417 Implement favorites + public async removeItemFromFavorites(): Promise { + throw new Error("Method not implemented."); + } + + public async createNewFolder( + parentItem: ContentItem, + folderName: string, + ): Promise { + try { + const response = await this.fileSystemApi.createFileOrDirectory({ + sessionId: this.sessionId, + fileOrDirectoryPath: this.trimComputePrefix(parentItem.uri), + fileProperties: { name: folderName, isDirectory: true }, + }); + + return this.filePropertiesToContentItem(response.data); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return; + } + } + + public async createNewItem( + parentItem: ContentItem, + fileName: string, + buffer?: ArrayBufferLike, + ): Promise { + try { + const response = await this.fileSystemApi.createFileOrDirectory({ + sessionId: this.sessionId, + fileOrDirectoryPath: this.trimComputePrefix(parentItem.uri), + fileProperties: { name: fileName, isDirectory: false }, + }); + + const contentItem = this.filePropertiesToContentItem(response.data); + this.updateFileMetadata( + this.trimComputePrefix(contentItem.uri), + response, + ); + + if (buffer) { + await this.updateContentOfItemAtPath( + this.trimComputePrefix(contentItem.uri), + new TextDecoder().decode(buffer), + ); + } + + return contentItem; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return; + } + } + + public async deleteItem(item: ContentItem): Promise { + const filePath = this.trimComputePrefix(item.uri); + try { + await this.fileSystemApi.deleteFileOrDirectoryFromSystem({ + sessionId: this.sessionId, + fileOrDirectoryPath: filePath, + ifMatch: "", + }); + delete this.fileMetadataMap[filePath]; + return true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return false; + } + } + + public async getChildItems(parentItem: ContentItem): Promise { + // If the user is fetching child items of the root folder, give them the + // "home" directory + if (parentItem.id === SERVER_FOLDER_ID) { + return [ + this.filePropertiesToContentItem( + createStaticFolder( + SAS_SERVER_HOME_DIRECTORY, + "Home", + SERVER_HOME_FOLDER_TYPE, + `/compute/sessions/${this.sessionId}/files/~fs~/members`, + "getDirectoryMembers", + ), + ), + ]; + } + + const allItems = []; + const limit = 100; + let start = 0; + let totalItemCount = 0; + do { + const response = await this.fileSystemApi.getDirectoryMembers({ + sessionId: this.sessionId, + directoryPath: this.trimComputePrefix( + getLink(parentItem.links, "GET", "getDirectoryMembers").uri, + ).replace("/members", ""), + limit, + start, + }); + totalItemCount = response.data.count; + + allItems.push( + ...response.data.items.map((childItem: FileProperties, index) => ({ + ...this.filePropertiesToContentItem(childItem), + uid: `${parentItem.uid}/${index + start}`, + })), + ); + + start += limit; + } while (start < totalItemCount); + + return allItems.sort((a, b) => { + const aIsDirectory = a.fileStat?.type === FileType.Directory; + const bIsDirectory = b.fileStat?.type === FileType.Directory; + if (aIsDirectory && !bIsDirectory) { + return -1; + } else if (!aIsDirectory && bIsDirectory) { + return 1; + } else { + return a.name.localeCompare(b.name); + } + }); + } + + public async getContentOfItem(item: ContentItem): Promise { + const path = this.trimComputePrefix(item.uri); + return await this.getContentOfItemAtPath(path); + } + + public async getContentOfUri(uri: Uri): Promise { + const path = this.trimComputePrefix(getResourceId(uri)); + return await this.getContentOfItemAtPath(path); + } + + private async getContentOfItemAtPath(path: string) { + const response = await this.fileSystemApi.getFileContentFromSystem( + { + sessionId: this.sessionId, + filePath: path, + }, + { + responseType: "arraybuffer", + }, + ); + + this.updateFileMetadata(path, response); + + // Disabling typescript checks on this line as this function is typed + // to return AxiosResponse. However, it appears to return + // AxiosResponse. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return response.data as unknown as string; + } + + public async getFolderPathForItem(): Promise { + // This is for creating a filename statement which won't work as expected for + // file system files. + return ""; + } + + public async getItemOfUri(uri: Uri): Promise { + const fileOrDirectoryPath = this.trimComputePrefix(getResourceId(uri)); + const response = await this.fileSystemApi.getFileorDirectoryProperties({ + sessionId: this.sessionId, + fileOrDirectoryPath, + }); + + this.updateFileMetadata(fileOrDirectoryPath, response); + + return this.filePropertiesToContentItem(response.data); + } + + public async getParentOfItem( + item: ContentItem, + ): Promise { + const fileOrDirectoryPath = this.getParentPathOfUri( + this.trimComputePrefix(item.uri), + ); + const response = await this.fileSystemApi.getFileorDirectoryProperties({ + sessionId: this.sessionId, + fileOrDirectoryPath, + }); + + return this.filePropertiesToContentItem(response.data); + } + + // TODO #417 Implement as part of favorites + public getRootFolder(): ContentItem | undefined { + return undefined; + } + + public async getRootItems(): Promise { + await this.setup(); + + for (let index = 0; index < SAS_SERVER_ROOT_FOLDERS.length; ++index) { + const delegateFolderName = SAS_SERVER_ROOT_FOLDERS[index]; + const result = + delegateFolderName === "@sasServerRoot" + ? { data: SAS_SERVER_ROOT_FOLDER } + : { data: {} }; + + this.rootFolders[delegateFolderName] = { + ...result.data, + uid: `${index}`, + ...this.filePropertiesToContentItem(result.data), + }; + } + + return this.rootFolders; + } + + public async getUriOfItem(item: ContentItem): Promise { + if (item.type !== "reference") { + return item.vscUri; + } + + return item.vscUri; + // TODO #417 Implement favorites + // // If we're attempting to open a favorite, open the underlying file instead. + // try { + // return (await this.getItemOfId(item.uri)).vscUri; + // } catch (error) { + // return item.vscUri; + // } + } + + public async moveItem( + item: ContentItem, + targetParentFolderUri: string, + ): Promise { + const currentFilePath = this.trimComputePrefix(item.uri); + const newFilePath = this.trimComputePrefix(targetParentFolderUri); + const { etag } = await this.getFileInfo(currentFilePath, true); + const params = { + sessionId: this.sessionId, + fileOrDirectoryPath: currentFilePath, + ifMatch: etag, + fileProperties: { + name: item.name, + path: newFilePath + .split(SAS_FILE_SEPARATOR) + .join("/") + .replace(/~sc~/g, ";"), + }, + }; + + const response = + await this.fileSystemApi.updateFileOrDirectoryOnSystem(params); + delete this.fileMetadataMap[currentFilePath]; + this.updateFileMetadata(newFilePath, response); + + return this.filePropertiesToContentItem(response.data).vscUri; + } + + public async renameItem( + item: ContentItem, + newName: string, + ): Promise { + const filePath = this.trimComputePrefix(item.uri); + + const parsedFilePath = filePath.split(SAS_FILE_SEPARATOR); + parsedFilePath.pop(); + const path = parsedFilePath.join("/"); + + try { + const response = await this.fileSystemApi.updateFileOrDirectoryOnSystem({ + sessionId: this.sessionId, + fileOrDirectoryPath: filePath, + ifMatch: "", + fileProperties: { name: newName, path }, + }); + + this.updateFileMetadata(filePath, response); + + return this.filePropertiesToContentItem(response.data); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return; + } + } + + public async updateContentOfItem(uri: Uri, content: string): Promise { + const filePath = this.trimComputePrefix(getResourceId(uri)); + return await this.updateContentOfItemAtPath(filePath, content); + } + + private async updateContentOfItemAtPath( + filePath: string, + content: string, + ): Promise { + const { etag } = await this.getFileInfo(filePath); + const data = { + sessionId: this.sessionId, + filePath, + // updateFileContentOnSystem requires body to be a File type. However, the + // underlying code is expecting a string. This forces compute to accept + // a string. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + body: content as unknown as File, + ifMatch: etag, + }; + const response = await this.fileSystemApi.updateFileContentOnSystem(data); + + this.updateFileMetadata(filePath, response); + } + + private getParentPathOfUri(uri: string) { + const uriPieces = uri.split(SAS_FILE_SEPARATOR); + uriPieces.pop(); + return uriPieces.join(SAS_FILE_SEPARATOR); + } + + private filePropertiesToContentItem( + fileProperties: FileProperties & { type?: string }, + flags?: ContentItem["flags"], + ): ContentItem { + const links = fileProperties.links.map((link) => ({ + method: link.method, + rel: link.rel, + href: link.href, + type: link.type, + uri: link.uri, + })); + + const id = getLink(links, "GET", "self").uri; + const isRootFolder = [SERVER_FOLDER_ID, SAS_SERVER_HOME_DIRECTORY].includes( + id, + ); + const item = { + id, + uri: id, + name: fileProperties.name, + creationTimeStamp: 0, + modifiedTimeStamp: new Date(fileProperties.modifiedTimeStamp).getTime(), + links, + permission: { + write: !isRootFolder && !fileProperties.readOnly, + delete: !isRootFolder && !fileProperties.readOnly, + addMember: + !!getLink(links, "POST", "makeDirectory") || + !!getLink(links, "POST", "createFile"), + }, + flags, + type: fileProperties.type || "", + parentFolderUri: this.getParentPathOfUri(id), + }; + + const typeName = getTypeName(item); + + return { + ...item, + contextValue: resourceType(item), + fileStat: { + ctime: item.creationTimeStamp, + mtime: item.modifiedTimeStamp, + size: 0, + type: + fileProperties.isDirectory || + FOLDER_TYPES.indexOf(typeName) >= 0 || + isRootFolder + ? FileType.Directory + : FileType.File, + }, + isReference: isReference(item), + resourceId: getResourceIdFromItem(item), + vscUri: getSasServerUri(item, flags?.isInRecycleBin || false), + typeName: getTypeName(item), + }; + } + + private trimComputePrefix(uri: string): string { + const uriWithoutPrefix = uri.replace( + /\/compute\/sessions\/[a-zA-Z0-9-]*\/files\//, + "", + ); + try { + return decodeURIComponent(uriWithoutPrefix); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return uriWithoutPrefix; + } + } + + private updateFileMetadata(id: string, { headers }: AxiosResponse) { + this.fileMetadataMap[id] = { + etag: headers.etag, + }; + + return this.fileMetadataMap[id]; + } + + private async getFileInfo(path: string, forceRefresh?: boolean) { + if (!forceRefresh && path in this.fileMetadataMap) { + return this.fileMetadataMap[path]; + } + + // If we don't have file metadata stored, lets attempt to fetch it + try { + const response = await this.fileSystemApi.getFileorDirectoryProperties({ + sessionId: this.sessionId, + fileOrDirectoryPath: path, + }); + return this.updateFileMetadata(path, response); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Intentionally blank + } + + return { + etag: "", + }; + } +} + +export default RestSASServerAdapter; diff --git a/client/src/connection/rest/SASContentAdapter.ts b/client/src/connection/rest/SASContentAdapter.ts index 295557977..0a6b85a92 100644 --- a/client/src/connection/rest/SASContentAdapter.ts +++ b/client/src/connection/rest/SASContentAdapter.ts @@ -15,7 +15,7 @@ import { FILE_TYPES, FOLDER_TYPES, ROOT_FOLDER, - ROOT_FOLDERS, + SAS_CONTENT_ROOT_FOLDERS, TRASH_FOLDER_TYPE, } from "../../components/ContentNavigator/const"; import { @@ -37,8 +37,8 @@ import { getPermission, getResourceId, getResourceIdFromItem, + getSasContentUri, getTypeName, - getUri, resourceType, } from "./util"; @@ -112,6 +112,7 @@ class SASContentAdapter implements ContentAdapter { const { data: result } = await this.connection.get( await this.generatedMembersUrlForParentItem(parentItem), ); + if (!result.items) { return Promise.reject(); } @@ -188,16 +189,16 @@ class SASContentAdapter implements ContentAdapter { public async moveItem( item: ContentItem, parentFolderUri: string, - ): Promise { + ): Promise { const newItemData = { ...item, parentFolderUri }; const updateLink = getLink(item.links, "PUT", "update"); try { - await this.connection.put(updateLink.uri, newItemData); + const response = await this.connection.put(updateLink.uri, newItemData); + return this.enrichWithDataProviderProperties(response.data).vscUri; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { - return false; + return; } - return true; } private async generatedMembersUrlForParentItem( @@ -272,8 +273,8 @@ class SASContentAdapter implements ContentAdapter { } public async getRootItems(): Promise { - for (let index = 0; index < ROOT_FOLDERS.length; ++index) { - const delegateFolderName = ROOT_FOLDERS[index]; + for (let index = 0; index < SAS_CONTENT_ROOT_FOLDERS.length; ++index) { + const delegateFolderName = SAS_CONTENT_ROOT_FOLDERS[index]; const result = delegateFolderName === "@sasRoot" ? { data: ROOT_FOLDER } @@ -343,9 +344,9 @@ class SASContentAdapter implements ContentAdapter { flags?: ContentItem["flags"], ): ContentItem { item.flags = flags; + item.permission = getPermission(item); return { ...item, - permission: getPermission(item), contextValue: resourceType(item), fileStat: { ctime: item.creationTimeStamp, @@ -355,7 +356,7 @@ class SASContentAdapter implements ContentAdapter { }, isReference: isReference(item), resourceId: getResourceIdFromItem(item), - vscUri: getUri(item, flags?.isInRecycleBin || false), + vscUri: getSasContentUri(item, flags?.isInRecycleBin || false), typeName: getTypeName(item), }; @@ -570,7 +571,7 @@ class SASContentAdapter implements ContentAdapter { } const success = await this.moveItem(item, recycleBinUri); - return recycleItemResponse(success); + return recycleItemResponse(!!success); function recycleItemResponse(success: boolean) { if (!success) { @@ -578,8 +579,8 @@ class SASContentAdapter implements ContentAdapter { } return { - newUri: getUri(item, true), - oldUri: getUri(item), + newUri: getSasContentUri(item, true), + oldUri: getSasContentUri(item), }; } } @@ -589,7 +590,7 @@ class SASContentAdapter implements ContentAdapter { if (!previousParentUri) { return false; } - return await this.moveItem(item, previousParentUri); + return !!(await this.moveItem(item, previousParentUri)); } private async updateAccessToken(): Promise { diff --git a/client/src/connection/rest/util.ts b/client/src/connection/rest/util.ts index 55e1cb9a9..cf23efc31 100644 --- a/client/src/connection/rest/util.ts +++ b/client/src/connection/rest/util.ts @@ -55,7 +55,8 @@ export const resourceType = (item: ContentItem): string | undefined => { if (!isValidItem(item)) { return; } - const { write, delete: del, addMember } = getPermission(item); + + const { write, delete: del, addMember } = item.permission; const isRecycled = isItemInRecycleBin(item); const actions = [ addMember && !isRecycled && "createChild", @@ -94,13 +95,20 @@ export const resourceType = (item: ContentItem): string | undefined => { return actions.sort().join("-"); }; -export const getUri = (item: ContentItem, readOnly?: boolean): Uri => +export const getSasContentUri = (item: ContentItem, readOnly?: boolean): Uri => Uri.parse( `${readOnly ? `${ContentSourceType.SASContent}ReadOnly` : ContentSourceType.SASContent}:/${ item.name }?id=${getResourceIdFromItem(item)}`, ); +export const getSasServerUri = (item: ContentItem, readOnly?: boolean): Uri => + Uri.parse( + `${readOnly ? `${ContentSourceType.SASServer}ReadOnly` : ContentSourceType.SASServer}:/${ + item.name + }?id=${getResourceIdFromItem(item)}`, + ); + export const getPermission = (item: ContentItem): Permission => { const itemType = getTypeName(item); return [FOLDER_TYPE, ...FILE_TYPES].includes(itemType) // normal folders and files diff --git a/client/src/node/extension.ts b/client/src/node/extension.ts index 4c812ca8e..6c60779f5 100644 --- a/client/src/node/extension.ts +++ b/client/src/node/extension.ts @@ -117,12 +117,22 @@ export function activate(context: ExtensionContext): void { sourceType: ContentSourceType.SASContent, treeIdentifier: "contentdataprovider", }); - // TODO #889 Create/use this - // const sasServerNavigator = new ContentNavigator(context, { - // mimeType: "application/vnd.code.tree.serverdataprovider", - // sourceType: "sasServer", - // treeIdentifier: "serverdataprovider", - // }); + const sasServerNavigator = new ContentNavigator(context, { + mimeType: "application/vnd.code.tree.serverdataprovider", + sourceType: ContentSourceType.SASServer, + treeIdentifier: "serverdataprovider", + }); + const handleFileUpdated = (e) => { + switch (e.type) { + case "rename": + sasDiagnostic.updateDiagnosticUri(e.uri, e.newUri); + break; + case "recycle": + case "delete": + sasDiagnostic.ignoreAll(e.uri); + break; + } + }; const resultPanelSubscriptionProvider = new ResultPanelSubscriptionProvider(); @@ -169,18 +179,10 @@ export function activate(context: ExtensionContext): void { getStatusBarItem(), ...libraryNavigator.getSubscriptions(), ...sasContentNavigator.getSubscriptions(), + ...sasServerNavigator.getSubscriptions(), ...resultPanelSubscriptionProvider.getSubscriptions(), - sasContentNavigator.onDidManipulateFile((e) => { - switch (e.type) { - case "rename": - sasDiagnostic.updateDiagnosticUri(e.uri, e.newUri); - break; - case "recycle": - case "delete": - sasDiagnostic.ignoreAll(e.uri); - break; - } - }), + sasContentNavigator.onDidManipulateFile(handleFileUpdated), + sasServerNavigator.onDidManipulateFile(handleFileUpdated), // If configFile setting is changed, update watcher to watch new configuration file workspace.onDidChangeConfiguration((event: ConfigurationChangeEvent) => { if (event.affectsConfiguration("SAS.connectionProfiles")) { diff --git a/client/test/components/ContentNavigator/ContentDataProvider.test.ts b/client/test/components/ContentNavigator/ContentDataProvider.test.ts index fef0e5686..4caa8fc65 100644 --- a/client/test/components/ContentNavigator/ContentDataProvider.test.ts +++ b/client/test/components/ContentNavigator/ContentDataProvider.test.ts @@ -26,7 +26,7 @@ import { ContentSourceType, } from "../../../src/components/ContentNavigator/types"; import SASContentAdapter from "../../../src/connection/rest/SASContentAdapter"; -import { getUri } from "../../../src/connection/rest/util"; +import { getSasContentUri as getUri } from "../../../src/connection/rest/util"; import { getUri as getTestUri } from "../../utils"; let stub; @@ -451,6 +451,16 @@ describe("ContentDataProvider", async function () { status: 409, }, }); + axiosInstance.get + .withArgs( + "uri://myFavorites/members?limit=1000000&filter=in(contentType,'file','dataFlow','RootFolder','folder','myFolder','favoritesFolder','userFolder','userRoot','trashFolder')&sortBy=eq(contentType,'folder'):descending,name:primary:ascending,type:ascending", + ) + .resolves({ data: { items: [] } }); + axiosInstance.get + .withArgs( + "uri://test/members?limit=1000000&filter=in(contentType,'file','dataFlow','RootFolder','folder','myFolder','favoritesFolder','userFolder','userRoot','trashFolder')&sortBy=eq(contentType,'folder'):descending,name:primary:ascending,type:ascending", + ) + .resolves({ data: [] }); const dataProvider = createDataProvider(); @@ -483,6 +493,16 @@ describe("ContentDataProvider", async function () { data: { ...origItem, name: "new-file.sas" }, headers: { etag: "1234", "last-modified": "5678" }, }); + axiosInstance.get + .withArgs( + "uri://myFavorites/members?limit=1000000&filter=in(contentType,'file','dataFlow','RootFolder','folder','myFolder','favoritesFolder','userFolder','userRoot','trashFolder')&sortBy=eq(contentType,'folder'):descending,name:primary:ascending,type:ascending", + ) + .resolves({ data: { items: [] } }); + axiosInstance.get + .withArgs( + "uri://rename/members?limit=1000000&filter=in(contentType,'file','dataFlow','RootFolder','folder','myFolder','favoritesFolder','userFolder','userRoot','trashFolder')&sortBy=eq(contentType,'folder'):descending,name:primary:ascending,type:ascending", + ) + .resolves({ data: { items: [] } }); const dataProvider = createDataProvider(); @@ -521,6 +541,16 @@ describe("ContentDataProvider", async function () { data: referencedFile, headers: { etag: "1234", "last-modified": "5678" }, }); + axiosInstance.get + .withArgs( + "uri://myFavorites/members?limit=1000000&filter=in(contentType,'file','dataFlow','RootFolder','folder','myFolder','favoritesFolder','userFolder','userRoot','trashFolder')&sortBy=eq(contentType,'folder'):descending,name:primary:ascending,type:ascending", + ) + .resolves({ data: { items: [] } }); + axiosInstance.get + .withArgs( + "uri://test/members?limit=1000000&filter=in(contentType,'file','dataFlow','RootFolder','folder','myFolder','favoritesFolder','userFolder','userRoot','trashFolder')&sortBy=eq(contentType,'folder'):descending,name:primary:ascending,type:ascending", + ) + .resolves({ data: { items: [] } }); await dataProvider.connect("http://test.io"); const uri: Uri = await dataProvider.renameResource( diff --git a/icons/dark/serverDark.svg b/icons/dark/serverDark.svg new file mode 100644 index 000000000..240e7f800 --- /dev/null +++ b/icons/dark/serverDark.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/icons/dark/userWorkspaceDark.svg b/icons/dark/userWorkspaceDark.svg new file mode 100644 index 000000000..94ae1f8f7 --- /dev/null +++ b/icons/dark/userWorkspaceDark.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/icons/light/serverLight.svg b/icons/light/serverLight.svg new file mode 100644 index 000000000..240e7f800 --- /dev/null +++ b/icons/light/serverLight.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/icons/light/userWorkspaceLight.svg b/icons/light/userWorkspaceLight.svg new file mode 100644 index 000000000..18fc00e4e --- /dev/null +++ b/icons/light/userWorkspaceLight.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/l10n/bundle.l10n.de.json b/l10n/bundle.l10n.de.json index 725d275b0..40b2d652f 100644 --- a/l10n/bundle.l10n.de.json +++ b/l10n/bundle.l10n.de.json @@ -103,7 +103,6 @@ "Task is complete.": "Der Task ist erledigt.", "The SAS session has closed.": "Die SAS Session wurde beendet.", "The file type is unsupported.": "Der Dateityp wird nicht unterstützt", - "The folder name cannot contain more than 100 characters.": "Der Ordnername kann nicht mehr als 100 Zeichen lang sein.", "The item could not be added to My Favorites.": "Der Inhalt konnte nicht zu Meinen Favoriten hinzugefügt werden.", "The item could not be removed from My Favorites.": "Der Inhalt konnte nicht aus Meinen Favoriten entfernt werden.", "The notebook file does not contain any code to convert.": "Das Notebook enthält keinen zu konvertierenden Code.", diff --git a/l10n/bundle.l10n.es.json b/l10n/bundle.l10n.es.json index 38486b13f..6b5730bed 100644 --- a/l10n/bundle.l10n.es.json +++ b/l10n/bundle.l10n.es.json @@ -103,7 +103,6 @@ "Task is complete.": "La tarea está completada.", "The SAS session has closed.": "Se ha cerrado la sesión SAS.", "The file type is unsupported.": "El tipo de archivo no es compatible.", - "The folder name cannot contain more than 100 characters.": "El nombre de la carpeta no puede contener más de 100 caracteres.", "The item could not be added to My Favorites.": "El elemento no se ha podido añadir a Mis favoritos.", "The item could not be removed from My Favorites.": "El elemento no se ha podido quitar de Mis favoritos.", "The notebook file does not contain any code to convert.": "El archivo de bloc de notas no contiene ningún código para convertir.", diff --git a/l10n/bundle.l10n.fr.json b/l10n/bundle.l10n.fr.json index 5784037f2..a5377e26e 100644 --- a/l10n/bundle.l10n.fr.json +++ b/l10n/bundle.l10n.fr.json @@ -103,7 +103,6 @@ "Task is complete.": "La tâche est terminée.", "The SAS session has closed.": "La session SAS s'est fermée.", "The file type is unsupported.": "Le type de fichier n'est pas pris en charge.", - "The folder name cannot contain more than 100 characters.": "Le nom du dossier ne peut pas contenir plus de 100 caractères.", "The item could not be added to My Favorites.": "Impossible d'ajouter l'élément à Mes favoris.", "The item could not be removed from My Favorites.": "Impossible de supprimer l'élément de Mes favoris.", "The notebook file does not contain any code to convert.": "Le fichier de notebook ne contient aucun code à convertir.", diff --git a/l10n/bundle.l10n.it.json b/l10n/bundle.l10n.it.json index 93c4257c9..926d6f053 100644 --- a/l10n/bundle.l10n.it.json +++ b/l10n/bundle.l10n.it.json @@ -103,7 +103,6 @@ "Task is complete.": "Il task è completo.", "The SAS session has closed.": "La sessione SAS è stata chiusa.", "The file type is unsupported.": "Il tipo di file non è supportato.", - "The folder name cannot contain more than 100 characters.": "Il nome della cartella non può contenere più di 100 caratteri.", "The item could not be added to My Favorites.": "Impossibile aggiungere l’elemento ai Preferiti.", "The item could not be removed from My Favorites.": "Impossibile rimuovere l’elemento dai Preferiti.", "The notebook file does not contain any code to convert.": "Il file del notebook non contiene codice da convertire.", diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index a78b27b3f..66c32c8a3 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -103,7 +103,6 @@ "Task is complete.": "タスクは完了しました。", "The SAS session has closed.": "SAS セッションが終了しました。", "The file type is unsupported.": "ファイルの種類はサポートされていません。", - "The folder name cannot contain more than 100 characters.": "フォルダー名は 100 文字を超えることはできません。", "The item could not be added to My Favorites.": "アイテムをお気に入りに追加できませんでした。", "The item could not be removed from My Favorites.": "アイテムをお気に入りから削除できませんでした。", "The notebook file does not contain any code to convert.": "ノートブックファイルには、変換するコードが含まれていません。", diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index 96a2776b0..8d53c62a5 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -103,7 +103,6 @@ "Task is complete.": "작업이 완료되었습니다.", "The SAS session has closed.": "SAS 세션이 종료되었습니다.", "The file type is unsupported.": "지원되지 않는 파일 형식입니다.", - "The folder name cannot contain more than 100 characters.": "폴더 이름은 100자를 초과할 수 없습니다.", "The item could not be added to My Favorites.": "항목을 즐겨찾기에 추가할 수 없습니다.", "The item could not be removed from My Favorites.": "항목을 즐겨찾기에서 제거할 수 없습니다.", "The notebook file does not contain any code to convert.": "노트북 파일에 변환할 코드가 포함되어 있지 않습니다.", diff --git a/l10n/bundle.l10n.pt-br.json b/l10n/bundle.l10n.pt-br.json index 4146050c8..d725e9577 100644 --- a/l10n/bundle.l10n.pt-br.json +++ b/l10n/bundle.l10n.pt-br.json @@ -103,7 +103,6 @@ "Task is complete.": "Tarefa completado.", "The SAS session has closed.": "A sessão de SAS foi encerrada.", "The file type is unsupported.": "Este tipo de arquivo não é compatível.", - "The folder name cannot contain more than 100 characters.": "O nome da pasta não pode conter mais de 100 caracteres.", "The item could not be added to My Favorites.": "Não foi possível adicionar o iten a My Favorites.", "The item could not be removed from My Favorites.": "Não foi possível excluir o iten do My Favorites.", "The notebook file does not contain any code to convert.": "O arquivo notebook não contém nenhum código para converter.", diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 33a59e307..198ef70a0 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -103,7 +103,6 @@ "Task is complete.": "任务完成。", "The SAS session has closed.": "SAS会话已关闭。", "The file type is unsupported.": "不支持该文件类型。", - "The folder name cannot contain more than 100 characters.": "文件夹名称不能超过100个字符。", "The item could not be added to My Favorites.": "该项目无法添加到我的收藏夹。", "The item could not be removed from My Favorites.": "无法从我的收藏夹中删除该项目。", "The notebook file does not contain any code to convert.": "笔记本文件不包含任何要转换的代码。", diff --git a/package.json b/package.json index bed0f97d6..45c0f1bb8 100644 --- a/package.json +++ b/package.json @@ -613,17 +613,114 @@ "icon": "$(refresh)" }, { - "command": "SAS.refreshLibraries", + "command": "SAS.content.collapseAllContent", + "title": "%commands.SAS.collapseAll%", + "category": "SAS", + "icon": "$(collapse-all)" + }, + { + "command": "SAS.content.downloadResource", + "title": "%commands.SAS.download%", + "category": "SAS" + }, + { + "command": "SAS.content.uploadResource", + "title": "%commands.SAS.upload%", + "category": "SAS" + }, + { + "command": "SAS.content.uploadFileResource", + "title": "%commands.SAS.uploadFiles%", + "category": "SAS" + }, + { + "command": "SAS.content.uploadFolderResource", + "title": "%commands.SAS.uploadFolders%", + "category": "SAS" + }, + { + "command": "SAS.server.deleteResource", + "title": "%commands.SAS.deleteResource%", + "category": "SAS" + }, + { + "command": "SAS.server.addFileResource", + "title": "%commands.SAS.addFileResource%", + "category": "SAS" + }, + { + "command": "SAS.server.addFolderResource", + "title": "%commands.SAS.addFolderResource%", + "category": "SAS" + }, + { + "command": "SAS.server.renameResource", + "title": "%commands.SAS.renameResource%", + "category": "SAS" + }, + { + "command": "SAS.server.restoreResource", + "title": "%commands.SAS.restoreResource%", + "category": "SAS" + }, + { + "command": "SAS.server.emptyRecycleBin", + "title": "%commands.SAS.emptyRecycleBin%", + "category": "SAS" + }, + { + "command": "SAS.server.addToFavorites", + "title": "%commands.SAS.addToFavorites%", + "category": "SAS" + }, + { + "command": "SAS.server.convertNotebookToFlow", + "title": "%commands.SAS.convertNotebookToFlow%", + "category": "SAS" + }, + { + "command": "SAS.server.removeFromFavorites", + "title": "%commands.SAS.removeFromFavorites%", + "category": "SAS" + }, + { + "command": "SAS.server.refreshContent", "title": "%commands.SAS.refresh%", "category": "SAS", "icon": "$(refresh)" }, { - "command": "SAS.content.collapseAllContent", + "command": "SAS.server.collapseAllContent", "title": "%commands.SAS.collapseAll%", "category": "SAS", "icon": "$(collapse-all)" }, + { + "command": "SAS.server.downloadResource", + "title": "%commands.SAS.download%", + "category": "SAS" + }, + { + "command": "SAS.server.uploadResource", + "title": "%commands.SAS.upload%", + "category": "SAS" + }, + { + "command": "SAS.server.uploadFileResource", + "title": "%commands.SAS.uploadFiles%", + "category": "SAS" + }, + { + "command": "SAS.server.uploadFolderResource", + "title": "%commands.SAS.uploadFolders%", + "category": "SAS" + }, + { + "command": "SAS.refreshLibraries", + "title": "%commands.SAS.refresh%", + "category": "SAS", + "icon": "$(refresh)" + }, { "command": "SAS.collapseAllLibraries", "title": "%commands.SAS.collapseAll%", @@ -657,26 +754,6 @@ "title": "%commands.SAS.file.new%", "category": "SAS" }, - { - "command": "SAS.content.downloadResource", - "title": "%commands.SAS.download%", - "category": "SAS" - }, - { - "command": "SAS.content.uploadResource", - "title": "%commands.SAS.upload%", - "category": "SAS" - }, - { - "command": "SAS.content.uploadFileResource", - "title": "%commands.SAS.uploadFiles%", - "category": "SAS" - }, - { - "command": "SAS.content.uploadFolderResource", - "title": "%commands.SAS.uploadFolders%", - "category": "SAS" - }, { "command": "SAS.saveHTML", "title": "%commands.SAS.download%", @@ -713,6 +790,16 @@ "when": "view == contentdataprovider", "group": "navigation@1" }, + { + "command": "SAS.server.refreshContent", + "when": "view == serverdataprovider", + "group": "navigation@0" + }, + { + "command": "SAS.server.collapseAllContent", + "when": "view == serverdataprovider", + "group": "navigation@1" + }, { "command": "SAS.refreshLibraries", "when": "view == librarydataprovider", @@ -806,6 +893,71 @@ "command": "SAS.content.uploadFolderResource", "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == contentdataprovider && !listMultiSelection && workspacePlatform != mac", "group": "uploaddownloadgroup@1" + }, + { + "command": "SAS.server.addFolderResource", + "when": "viewItem =~ /createChild/ && view == serverdataprovider", + "group": "addgroup@0" + }, + { + "command": "SAS.server.addFileResource", + "when": "viewItem =~ /createChild/ && view == serverdataprovider", + "group": "addgroup@1" + }, + { + "command": "SAS.server.addToFavorites", + "when": "viewItem =~ /addToFavorites/ && view == serverdataprovider", + "group": "favoritesgroup@0" + }, + { + "command": "SAS.server.removeFromFavorites", + "when": "viewItem =~ /removeFromFavorites/ && view == serverdataprovider", + "group": "favoritesgroup@1" + }, + { + "command": "SAS.server.renameResource", + "when": "viewItem =~ /update/ && view == serverdataprovider && !listMultiSelection", + "group": "delrenamegroup@0" + }, + { + "command": "SAS.server.deleteResource", + "when": "viewItem =~ /delete/ && view == serverdataprovider", + "group": "delrenamegroup@1" + }, + { + "command": "SAS.server.convertNotebookToFlow", + "when": "viewItem =~ /convertNotebookToFlow/ && view == serverdataprovider", + "group": "actionsgroup@0" + }, + { + "command": "SAS.server.restoreResource", + "when": "viewItem =~ /restore/ && view == serverdataprovider", + "group": "restoregroup@0" + }, + { + "command": "SAS.server.emptyRecycleBin", + "when": "viewItem =~ /empty/ && view == serverdataprovider", + "group": "emptygroup@0" + }, + { + "command": "SAS.server.downloadResource", + "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == serverdataprovider", + "group": "uploaddownloadgroup@0" + }, + { + "command": "SAS.server.uploadResource", + "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == serverdataprovider && !listMultiSelection && workspacePlatform == mac", + "group": "uploaddownloadgroup@1" + }, + { + "command": "SAS.server.uploadFileResource", + "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == serverdataprovider && !listMultiSelection && workspacePlatform != mac", + "group": "uploaddownloadgroup@1" + }, + { + "command": "SAS.server.uploadFolderResource", + "when": "(viewItem =~ /update/ || viewItem =~ /createChild/) && view == serverdataprovider && !listMultiSelection && workspacePlatform != mac", + "group": "uploaddownloadgroup@1" } ], "editor/title/run": [ @@ -1023,6 +1175,11 @@ "name": "%views.SAS.explorer%", "when": "SAS.authorized && SAS.connectionType == rest" }, + { + "id": "serverdataprovider", + "name": "%views.SAS.serverExplorer%", + "when": "SAS.authorized && SAS.connectionType == rest" + }, { "id": "librarydataprovider", "name": "%views.SAS.libraries%", diff --git a/package.nls.de.json b/package.nls.de.json index cb9600659..8e72b6cb0 100644 --- a/package.nls.de.json +++ b/package.nls.de.json @@ -71,7 +71,6 @@ "themes.SAS.dark": "SAS Dunkel", "themes.SAS.highContrast": "SAS Hoher Kontrast", "themes.SAS.light": "SAS Hell", - "views.SAS.explorer": "Explorer", "views.SAS.libraries": "Bibliotheken", "views.SAS.signIn": "Anmelden", "views.SAS.unsupportedConnection": "Ihre Verbindung unterstützt nicht die Navigation in SAS-Inhalten innerhalb der SAS Extension for Visual Studio Code. Sie können nur über die Explorer-Ansicht auf Dateien zugreifen.", diff --git a/package.nls.es.json b/package.nls.es.json index ed347ee22..fcff74f62 100644 --- a/package.nls.es.json +++ b/package.nls.es.json @@ -71,7 +71,6 @@ "themes.SAS.dark": "SAS Dark", "themes.SAS.highContrast": "SAS High Contrast", "themes.SAS.light": "SAS Light", - "views.SAS.explorer": "Explorador", "views.SAS.libraries": "Librerías", "views.SAS.signIn": "Conexión", "views.SAS.unsupportedConnection": "La conexión no permite la navegación de contenido SAS dentro de SAS Extension for Visual Studio Code. Sólo puede acceder a los archivos utilizando la vista del explorador.", diff --git a/package.nls.fr.json b/package.nls.fr.json index 6d13e0bac..e7c6da8e9 100644 --- a/package.nls.fr.json +++ b/package.nls.fr.json @@ -71,7 +71,6 @@ "themes.SAS.dark": "SAS Dark", "themes.SAS.highContrast": "SAS High Contrast", "themes.SAS.light": "SAS Light", - "views.SAS.explorer": "Explorateur", "views.SAS.libraries": "Bibliothèques", "views.SAS.signIn": "Se connecter", "views.SAS.unsupportedConnection": "Cette connexion ne prend pas en charge la navigation dans le contenu SAS via l'extension Visual Studio Code. Vous ne pouvez accéder aux fichiers qu'à travers l'Explorateur.", diff --git a/package.nls.it.json b/package.nls.it.json index 002d22439..cebc01af4 100644 --- a/package.nls.it.json +++ b/package.nls.it.json @@ -71,7 +71,6 @@ "themes.SAS.dark": "SAS Dark", "themes.SAS.highContrast": "SAS High Contrast", "themes.SAS.light": "SAS Light", - "views.SAS.explorer": "Explorer", "views.SAS.libraries": "Librerie", "views.SAS.signIn": "Accedi", "views.SAS.unsupportedConnection": "La connessione non supporta la navigazione nel contenuto SAS all'interno di SAS Extension for Visual Studio Code. È possibile accedere ai file solo utilizzando la visualizzazione Explorer.", diff --git a/package.nls.ja.json b/package.nls.ja.json index 3be141143..214a49ef5 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -71,7 +71,6 @@ "themes.SAS.dark": "SAS Dark", "themes.SAS.highContrast": "SAS High Contrast", "themes.SAS.light": "SAS Light", - "views.SAS.explorer": "エクスプローラー", "views.SAS.libraries": "ライブラリ", "views.SAS.signIn": "サインイン", "views.SAS.unsupportedConnection": "この接続では、SAS Extension for Visual Studio Code 内の SAS コンテンツナビゲーションがサポートされていません。ファイルにアクセスできるのは、エクスプローラービューのみです。", diff --git a/package.nls.json b/package.nls.json index ae6fd6022..2f306485b 100644 --- a/package.nls.json +++ b/package.nls.json @@ -72,8 +72,9 @@ "themes.SAS.dark": "SAS Dark", "themes.SAS.highContrast": "SAS High Contrast", "themes.SAS.light": "SAS Light", - "views.SAS.explorer": "Explorer", + "views.SAS.explorer": "SAS Content", "views.SAS.libraries": "Libraries", + "views.SAS.serverExplorer": "SAS Server", "views.SAS.signIn": "Sign In", "views.SAS.unsupportedConnection": "Your connection does not support SAS content navigation within the SAS Extension for Visual Studio Code. You can access files only using the Explorer view.", "views.SAS.welcome": "To use the SAS Extension for Visual Studio Code, you must sign in to SAS.\n[Sign In](command:SAS.authorize)" diff --git a/package.nls.ko.json b/package.nls.ko.json index f531b2f98..358aaefa3 100644 --- a/package.nls.ko.json +++ b/package.nls.ko.json @@ -71,7 +71,6 @@ "themes.SAS.dark": "SAS 다크", "themes.SAS.highContrast": "SAS 고대비", "themes.SAS.light": "SAS 라이트", - "views.SAS.explorer": "탐색기", "views.SAS.libraries": "라이브러리", "views.SAS.signIn": "로그인", "views.SAS.unsupportedConnection": "귀하의 연결은 SAS Extension for Visual Studio Code 내에서 SAS 콘텐츠 탐색을 지원하지 않습니다. 탐색기 뷰를 사용하여야 파일을 방문할 수 있습니다.", diff --git a/package.nls.pt-br.json b/package.nls.pt-br.json index 8e679a429..54c694c22 100644 --- a/package.nls.pt-br.json +++ b/package.nls.pt-br.json @@ -71,7 +71,6 @@ "themes.SAS.dark": "SAS Escuro", "themes.SAS.highContrast": "SAS Alta Contrasto", "themes.SAS.light": "SAS Clara", - "views.SAS.explorer": "Explorador", "views.SAS.libraries": "Bibliotecas", "views.SAS.signIn": "Sign In", "views.SAS.unsupportedConnection": "Este conexão não suporte a navegação de conteúdo SAS na extensão Visual Studio Code SAS. Somente pode acessar arquivos usando o Explorador.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 2abe3f7d4..ed03dec26 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -71,7 +71,6 @@ "themes.SAS.dark": "SAS 深色", "themes.SAS.highContrast": "SAS 高对比度", "themes.SAS.light": "SAS 浅色", - "views.SAS.explorer": "资源管理器", "views.SAS.libraries": "库", "views.SAS.signIn": "登录", "views.SAS.unsupportedConnection": "您的连接不支持 SAS Extension for Visual Studio Code 中的 SAS 内容导航。您只能使用资源管理器视图访问文件。", diff --git a/website/docs/Features/accessServer.md b/website/docs/Features/accessServer.md new file mode 100644 index 000000000..2b8c4847f --- /dev/null +++ b/website/docs/Features/accessServer.md @@ -0,0 +1,23 @@ +--- +sidebar_position: 3 +--- + +# Accessing SAS Server + +After you configure the SAS extension for a SAS Viya environment, you can access SAS Server. + +To access SAS Server: + +1. Click the SAS icon in the VS Code activity bar. +2. Click `Sign In`. +3. Your SAS Server files should be displayed after you sign in. You can create, edit, delete, upload, download, and run files stored on a SAS server. + +:::info note + +SAS Server requires a profile with a connection to a SAS Viya server. + +::: + +## Drag and Drop + +- You can drag and drop files and folders between the SAS Server pane and File Explorer. diff --git a/website/docs/Features/sasCodeEditing.md b/website/docs/Features/sasCodeEditing.md index bf7fb2e1d..3f5423230 100644 --- a/website/docs/Features/sasCodeEditing.md +++ b/website/docs/Features/sasCodeEditing.md @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 7 --- # SAS Code Editing Features diff --git a/website/docs/Features/sasNotebook.md b/website/docs/Features/sasNotebook.md index f87d1e985..f07d71ba5 100644 --- a/website/docs/Features/sasNotebook.md +++ b/website/docs/Features/sasNotebook.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 8 +--- + # SAS Notebook SAS Notebook is an interactive notebook file that includes markdown code, executable code snippets, and corresponding rich output cells. diff --git a/website/docs/matrix.md b/website/docs/matrix.md index 0f3b2c2a8..8bf4a3671 100644 --- a/website/docs/matrix.md +++ b/website/docs/matrix.md @@ -9,6 +9,7 @@ sidebar_position: 2 | [SAS Options settings](./Configurations/Profiles/additional.md#sas-options-settings-examples) | :heavy_check_mark: | :heavy_check_mark:\* | :heavy_check_mark: | \*Startup options not supported for SAS 9.4 (local) and (remote-IOM) | | [SAS Autoexec settings](./Configurations/Profiles/additional.md#sas-autoexec-settings) | :heavy_check_mark: | :x: | :x: | | [Access SAS Content](./Features/accessContent.md) | :heavy_check_mark: | :x: | :x: | +| [Access SAS Server](./Features/accessServer.md) | :heavy_check_mark: | :x: | :x: | SAS 9.4 and SSH support to be added in a future release | | [Access connected libraries](./Features/accessLibraries.md) | :heavy_check_mark: | :heavy_check_mark: | :x: | | [Table viewer](./Features/accessLibraries.md) | :heavy_check_mark: | :heavy_check_mark: | :x: | | [SAS Notebooks](./Features/sasNotebook.md) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |