From 3e08a4b44b323800cc55ea64ca92fdda64a755ce Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 23 Aug 2024 15:23:09 -0400 Subject: [PATCH 01/30] chore: stub RestSASServerAdapter Signed-off-by: Scott Dover --- .../ContentNavigator/ContentAdapterFactory.ts | 3 + .../src/components/ContentNavigator/index.ts | 6 +- .../connection/rest/RestSASServerAdapter.ts | 130 +++++++++++ client/src/node/extension.ts | 36 ++-- package.json | 201 ++++++++++++++++-- package.nls.json | 1 + 6 files changed, 336 insertions(+), 41 deletions(-) create mode 100644 client/src/connection/rest/RestSASServerAdapter.ts diff --git a/client/src/components/ContentNavigator/ContentAdapterFactory.ts b/client/src/components/ContentNavigator/ContentAdapterFactory.ts index 52bb3caad..3a3c207b7 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 { @@ -16,6 +17,8 @@ class ContentAdapterFactory { ): 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/index.ts b/client/src/components/ContentNavigator/index.ts index 88980006a..82a2aaffc 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -54,8 +54,11 @@ 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 +67,6 @@ class ContentNavigator implements SubscriptionProvider { context.extensionUri, config, ); - this.sourceType = config.sourceType; workspace.registerFileSystemProvider( config.sourceType, @@ -269,7 +271,7 @@ class ContentNavigator implements SubscriptionProvider { ), commands.registerCommand(`${SAS}.collapseAllContent`, () => { commands.executeCommand( - "workbench.actions.treeView.contentdataprovider.collapseAll", + `workbench.actions.treeView.${this.treeIdentifier}.collapseAll`, ); }), commands.registerCommand( diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts new file mode 100644 index 000000000..0d57ec058 --- /dev/null +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -0,0 +1,130 @@ +import { Uri } from "vscode"; + +import { + AddChildItemProperties, + ContentAdapter, + ContentItem, + RootFolderMap, +} from "../../components/ContentNavigator/types"; + +class RestSASServerAdapter implements ContentAdapter { + public async connect(baseUrl: string): Promise { + // TODO + return; + } + public connected(): boolean { + // TODO + return true; + } + + public async addChildItem( + childItemUri: string | undefined, + parentItemUri: string | undefined, + properties: AddChildItemProperties, + ): Promise { + throw new Error("Method not implemented."); + } + + public async addItemToFavorites(item: ContentItem): Promise { + throw new Error("Method not implemented."); + } + + public async createNewFolder( + parentItem: ContentItem, + folderName: string, + ): Promise { + throw new Error("Method not implemented."); + } + + public async createNewItem( + parentItem: ContentItem, + fileName: string, + buffer?: ArrayBufferLike, + ): Promise { + throw new Error("Method not implemented."); + } + + public async deleteItem(item: ContentItem): Promise { + throw new Error("Method not implemented."); + } + + public async getChildItems(parentItem: ContentItem): Promise { + throw new Error("Method not implemented."); + } + + public async getContentOfItem(item: ContentItem): Promise { + throw new Error("Method not implemented."); + } + + public async getContentOfUri(uri: Uri): Promise { + throw new Error("Method not implemented."); + } + + public async getFolderPathForItem(item: ContentItem): Promise { + throw new Error("Method not implemented."); + } + + public async getItemOfId(id: string): Promise { + throw new Error("Method not implemented."); + } + + public async getItemOfUri(uri: Uri): Promise { + throw new Error("Method not implemented."); + } + + public async getParentOfItem( + item: ContentItem, + ): Promise { + throw new Error("Method not implemented."); + } + + public getRootFolder(name: string): ContentItem | undefined { + throw new Error("Method not implemented."); + } + + public async getRootItems(): Promise { + // TODO + return {}; + } + + public async getUriOfItem( + item: ContentItem, + readOnly: boolean, + ): Promise { + throw new Error("Method not implemented."); + } + + public async moveItem( + item: ContentItem, + targetParentFolderUri: string, + ): Promise { + throw new Error("Method not implemented."); + } + + public async recycleItem( + item: ContentItem, + ): Promise<{ newUri?: Uri; oldUri?: Uri }> { + throw new Error("Method not implemented."); + } + + public async removeItemFromFavorites(item: ContentItem): Promise { + throw new Error("Method not implemented."); + } + + public async renameItem( + item: ContentItem, + newName: string, + ): Promise { + throw new Error("Method not implemented."); + } + + public async restoreItem(item: ContentItem): Promise { + throw new Error("Method not implemented."); + } + + public async updateContentOfItem(uri: Uri, content: string): Promise { + throw new Error("Method not implemented."); + } +} + +export default RestSASServerAdapter; 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/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.json b/package.nls.json index ae6fd6022..7e6184561 100644 --- a/package.nls.json +++ b/package.nls.json @@ -74,6 +74,7 @@ "themes.SAS.light": "SAS Light", "views.SAS.explorer": "Explorer", "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)" From 7ae63a0c899809ac5e91f1e6b87f018318a66b57 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 30 Aug 2024 11:24:41 -0400 Subject: [PATCH 02/30] chore: add ability to view/open files and folders Signed-off-by: Scott Dover --- .../ContentNavigator/ContentModel.ts | 6 +- .../src/components/ContentNavigator/const.ts | 50 ++-- .../src/components/ContentNavigator/utils.ts | 29 ++ .../connection/rest/RestSASServerAdapter.ts | 259 ++++++++++++++++-- .../src/connection/rest/SASContentAdapter.ts | 14 +- client/src/connection/rest/util.ts | 9 +- 6 files changed, 312 insertions(+), 55 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index d4dd9fc6b..55f1b3536 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Uri } from "vscode"; -import { Messages, ROOT_FOLDERS } from "./const"; +import { ALL_ROOT_FOLDERS, Messages } from "./const"; import { ContentAdapter, ContentItem } from "./types"; export class ContentModel { @@ -33,7 +33,9 @@ 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]), + // TODO MEEEE! + (a, b) => + ALL_ROOT_FOLDERS.indexOf(a[0]) - ALL_ROOT_FOLDERS.indexOf(b[0]), ) .map((entry) => entry[1]); } diff --git a/client/src/components/ContentNavigator/const.ts b/client/src/components/ContentNavigator/const.ts index b03420e3a..04335be93 100644 --- a/client/src/components/ContentNavigator/const.ts +++ b/client/src/components/ContentNavigator/const.ts @@ -2,32 +2,27 @@ // 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 SAS_SERVER_ROOT_FOLDER = createStaticFolder( + SERVER_FOLDER_ID, + "SAS Server", + ROOT_FOLDER_TYPE, + "/", + "getDirectoryMembers", +); export const FILE_TYPE = "file"; export const DATAFLOW_TYPE = "dataFlow"; @@ -46,13 +41,24 @@ 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 = [ + // "@myFavorites", + "@sasServerRoot", + // "@myRecycleBin", +]; + +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."), diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index 84afb107c..ee60f2736 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -47,3 +47,32 @@ 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", + }, + ], +}); diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 0d57ec058..d8d339f4b 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -1,22 +1,69 @@ -import { Uri } from "vscode"; +import { FileType, Uri } from "vscode"; +import { getSession } from ".."; +import { + FOLDER_TYPES, + SAS_SERVER_ROOT_FOLDER, + SAS_SERVER_ROOT_FOLDERS, + SERVER_FOLDER_ID, +} from "../../components/ContentNavigator/const"; import { AddChildItemProperties, ContentAdapter, ContentItem, RootFolderMap, } from "../../components/ContentNavigator/types"; +import { + createStaticFolder, + isItemInRecycleBin, + isReference, +} from "../../components/ContentNavigator/utils"; +import { appendSessionLogFn } from "../../components/logViewer"; +import { FileProperties, FileSystemApi } from "./api/compute"; +import { getApiConfig } from "./common"; +import { + getLink, + getPermission, + getResourceId, + getResourceIdFromItem, + getSasServerUri, + getTypeName, + resourceType, +} from "./util"; class RestSASServerAdapter implements ContentAdapter { - public async connect(baseUrl: string): Promise { - // TODO - return; + protected baseUrl: string; + protected fileSystemApi: ReturnType; + protected sessionId: string; + private rootFolders: RootFolderMap; + + public constructor() { + this.rootFolders = {}; + } + + public async connect(): Promise { + const session = getSession(); + session.onSessionLogFn = appendSessionLogFn; + + await session.setup(true); + + this.sessionId = session?.sessionId(); + this.fileSystemApi = FileSystemApi(getApiConfig()); } + public connected(): boolean { // TODO return true; } + public async setup(): Promise { + if (this.sessionId && this.fileSystemApi) { + return; + } + + await this.connect(); + } + public async addChildItem( childItemUri: string | undefined, parentItemUri: string | undefined, @@ -26,14 +73,14 @@ class RestSASServerAdapter implements ContentAdapter { } public async addItemToFavorites(item: ContentItem): Promise { - throw new Error("Method not implemented."); + throw new Error("fds Method not implemented."); } public async createNewFolder( parentItem: ContentItem, folderName: string, ): Promise { - throw new Error("Method not implemented."); + throw new Error("cnf Method not implemented."); } public async createNewItem( @@ -41,57 +88,152 @@ class RestSASServerAdapter implements ContentAdapter { fileName: string, buffer?: ArrayBufferLike, ): Promise { - throw new Error("Method not implemented."); + throw new Error("cni Method not implemented."); } public async deleteItem(item: ContentItem): Promise { - throw new Error("Method not implemented."); + throw new Error("di Method not implemented."); } public async getChildItems(parentItem: ContentItem): Promise { - throw new Error("Method not implemented."); + // If the user is fetching child items of the root folder, give them the + // "home" directory + const id = "SAS_SERVER_HOME_DIRECTORY"; + if (parentItem.id === SERVER_FOLDER_ID) { + return [ + this.enrichWithDataProviderProperties({ + ...createStaticFolder( + id, + "Home", + "userRoot", + `/compute/sessions/${this.sessionId}/files/~fs~/members`, + "getDirectoryMembers", + ), + creationTimeStamp: 0, + modifiedTimeStamp: 0, + permission: undefined, + }), + ]; + } + + const { data } = await this.fileSystemApi.getDirectoryMembers({ + sessionId: this.sessionId, + directoryPath: parseMemberUri( + getLink(parentItem.links, "GET", "getDirectoryMembers").uri, + this.sessionId, + ), + }); + + // TODO (sas-server) We need to paginate and sort results + return data.items.map((childItem: FileProperties, index) => ({ + ...this.filePropertiesToContentItem(childItem), + uid: `${parentItem.uid}/${index}`, + ...this.enrichWithDataProviderProperties( + this.filePropertiesToContentItem(childItem), + ), + })); + + function parseMemberUri(uri: string, sessionId: string): string { + return uri + .replace(`/compute/sessions/${sessionId}/files/`, "") + .replace("/members", ""); + } } public async getContentOfItem(item: ContentItem): Promise { - throw new Error("Method not implemented."); + throw new Error("getContentOfItem"); } public async getContentOfUri(uri: Uri): Promise { - throw new Error("Method not implemented."); + // TODO (sas-server) We're using this a bunch. Make things more better-er + const path = getResourceId(uri).replace( + `/compute/sessions/${this.sessionId}/files/`, + "", + ); + + const { data } = await this.fileSystemApi.getFileContentFromSystem( + { + sessionId: this.sessionId, + filePath: path, + }, + { + responseType: "arraybuffer", + }, + ); + + // 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 data as unknown as string; } public async getFolderPathForItem(item: ContentItem): Promise { - throw new Error("Method not implemented."); + throw new Error("getFolderPathForItem Method not implemented."); } public async getItemOfId(id: string): Promise { - throw new Error("Method not implemented."); + throw new Error("getItemOfId Method not implemented."); } public async getItemOfUri(uri: Uri): Promise { - throw new Error("Method not implemented."); + const resourceId = getResourceId(uri); + const { data } = await this.fileSystemApi.getFileorDirectoryProperties({ + sessionId: this.sessionId, + // TODO (sas-server) cleanup/reuse this + fileOrDirectoryPath: resourceId.replace( + `/compute/sessions/${this.sessionId}/files/`, + "", + ), + }); + + return this.enrichWithDataProviderProperties( + this.filePropertiesToContentItem(data), + ); } public async getParentOfItem( item: ContentItem, ): Promise { - throw new Error("Method not implemented."); + throw new Error("getParentOfItem Method not implemented."); } public getRootFolder(name: string): ContentItem | undefined { - throw new Error("Method not implemented."); + throw new Error("getRootFolder Method not implemented."); } public async getRootItems(): Promise { - // TODO - return {}; + 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.enrichWithDataProviderProperties(result.data), + }; + } + + return this.rootFolders; } - public async getUriOfItem( - item: ContentItem, - readOnly: boolean, - ): Promise { - throw new Error("Method not implemented."); + public async getUriOfItem(item: ContentItem): Promise { + if (item.type !== "reference") { + return item.vscUri; + } + + return item.vscUri; + // // 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( @@ -125,6 +267,77 @@ class RestSASServerAdapter implements ContentAdapter { public async updateContentOfItem(uri: Uri, content: string): Promise { throw new Error("Method not implemented."); } + + private enrichWithDataProviderProperties( + item: ContentItem, + flags?: ContentItem["flags"], + ): ContentItem { + item.flags = flags; + return { + ...item, + permission: getPermission(item), + contextValue: resourceType(item), + fileStat: { + ctime: item.creationTimeStamp, + mtime: item.modifiedTimeStamp, + size: 0, + type: getIsContainer(item) ? FileType.Directory : FileType.File, + }, + isReference: isReference(item), + resourceId: getResourceIdFromItem(item), + vscUri: getSasServerUri(item, flags?.isInRecycleBin || false), + typeName: getTypeName(item), + }; + + function getIsContainer(item: ContentItem): boolean { + if (item.fileStat?.type === FileType.Directory) { + return true; + } + + const typeName = getTypeName(item); + if (isItemInRecycleBin(item) && isReference(item)) { + return false; + } + if (FOLDER_TYPES.indexOf(typeName) >= 0) { + return true; + } + return false; + } + } + + private filePropertiesToContentItem( + fileProperties: FileProperties, + ): 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; + return { + id, + uri: id, + name: fileProperties.name, + creationTimeStamp: 0, + modifiedTimeStamp: new Date(fileProperties.modifiedTimeStamp).getTime(), + links, + // These will be overwritten + permission: { + write: false, + delete: false, + addMember: false, + }, + fileStat: { + type: fileProperties.isDirectory ? FileType.Directory : FileType.File, + ctime: 0, + mtime: 0, + size: 0, + }, + }; + } } export default RestSASServerAdapter; diff --git a/client/src/connection/rest/SASContentAdapter.ts b/client/src/connection/rest/SASContentAdapter.ts index 295557977..7a8323793 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"; @@ -272,8 +272,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 } @@ -355,7 +355,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), }; @@ -578,8 +578,8 @@ class SASContentAdapter implements ContentAdapter { } return { - newUri: getUri(item, true), - oldUri: getUri(item), + newUri: getSasContentUri(item, true), + oldUri: getSasContentUri(item), }; } } diff --git a/client/src/connection/rest/util.ts b/client/src/connection/rest/util.ts index 55e1cb9a9..d152972e1 100644 --- a/client/src/connection/rest/util.ts +++ b/client/src/connection/rest/util.ts @@ -94,13 +94,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 From 4d47e7de44d1193bf922c2922087386deac45f5a Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 6 Sep 2024 14:13:24 -0400 Subject: [PATCH 03/30] feat: add ability to create files and folders Signed-off-by: Scott Dover --- .../connection/rest/RestSASServerAdapter.ts | 40 ++++++++++++++++++- client/src/connection/rest/util.ts | 11 +++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index d8d339f4b..36c3ad4ca 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -80,7 +80,18 @@ class RestSASServerAdapter implements ContentAdapter { parentItem: ContentItem, folderName: string, ): Promise { - throw new Error("cnf Method not implemented."); + const response = await this.fileSystemApi.createFileOrDirectory({ + sessionId: this.sessionId, + fileOrDirectoryPath: parentItem.uri.replace( + `/compute/sessions/${this.sessionId}/files/`, + "", + ), + fileProperties: { name: folderName, isDirectory: true }, + }); + + return this.enrichWithDataProviderProperties( + this.filePropertiesToContentItem(response.data), + ); } public async createNewItem( @@ -88,7 +99,32 @@ class RestSASServerAdapter implements ContentAdapter { fileName: string, buffer?: ArrayBufferLike, ): Promise { - throw new Error("cni Method not implemented."); + const response = await this.fileSystemApi.createFileOrDirectory({ + sessionId: this.sessionId, + fileOrDirectoryPath: parentItem.uri.replace( + `/compute/sessions/${this.sessionId}/files/`, + "", + ), + fileProperties: { name: fileName, isDirectory: false }, + }); + + if (buffer) { + const etag = response.headers.etag; + const filePath = getLink(response.data.links, "GET", "self").uri.replace( + `/compute/sessions/${this.sessionId}/files/`, + "", + ); + await this.fileSystemApi.updateFileContentOnSystem({ + sessionId: this.sessionId, + filePath, + body: new File([buffer], response.data.name), + ifMatch: etag, + }); + } + + return this.enrichWithDataProviderProperties( + this.filePropertiesToContentItem(response.data), + ); } public async deleteItem(item: ContentItem): Promise { diff --git a/client/src/connection/rest/util.ts b/client/src/connection/rest/util.ts index d152972e1..07f7d70d9 100644 --- a/client/src/connection/rest/util.ts +++ b/client/src/connection/rest/util.ts @@ -1,6 +1,6 @@ // Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Uri } from "vscode"; +import { FileType, Uri } from "vscode"; import { DATAFLOW_TYPE, @@ -55,6 +55,7 @@ export const resourceType = (item: ContentItem): string | undefined => { if (!isValidItem(item)) { return; } + const { write, delete: del, addMember } = getPermission(item); const isRecycled = isItemInRecycleBin(item); const actions = [ @@ -110,11 +111,15 @@ export const getSasServerUri = (item: ContentItem, readOnly?: boolean): Uri => export const getPermission = (item: ContentItem): Permission => { const itemType = getTypeName(item); - return [FOLDER_TYPE, ...FILE_TYPES].includes(itemType) // normal folders and files + return [FOLDER_TYPE, ...FILE_TYPES].includes(itemType) || + item.fileStat?.type === FileType.Directory ? { write: !!getLink(item.links, "PUT", "update"), delete: !!getLink(item.links, "DELETE", "deleteResource"), - addMember: !!getLink(item.links, "POST", "createChild"), + addMember: + !!getLink(item.links, "POST", "createChild") || + !!getLink(item.links, "POST", "makeDirectory") || + !!getLink(item.links, "POST", "createFile"), } : { // delegate folders, user folder and user root folder From ae714053a74dfba181bada34fc98674d2219c60e Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 6 Sep 2024 16:03:16 -0400 Subject: [PATCH 04/30] feat: allow update/rename of files and folders Signed-off-by: Scott Dover --- .../connection/rest/RestSASServerAdapter.ts | 209 +++++++++--------- .../src/connection/rest/SASContentAdapter.ts | 2 +- client/src/connection/rest/util.ts | 12 +- 3 files changed, 114 insertions(+), 109 deletions(-) diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 36c3ad4ca..16fb6692b 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -1,5 +1,7 @@ import { FileType, Uri } from "vscode"; +import { AxiosResponse } from "axios"; + import { getSession } from ".."; import { FOLDER_TYPES, @@ -15,7 +17,6 @@ import { } from "../../components/ContentNavigator/types"; import { createStaticFolder, - isItemInRecycleBin, isReference, } from "../../components/ContentNavigator/utils"; import { appendSessionLogFn } from "../../components/logViewer"; @@ -23,7 +24,6 @@ import { FileProperties, FileSystemApi } from "./api/compute"; import { getApiConfig } from "./common"; import { getLink, - getPermission, getResourceId, getResourceIdFromItem, getSasServerUri, @@ -31,14 +31,20 @@ import { resourceType, } from "./util"; +const SAS_SERVER_HOME_DIRECTORY = "SAS_SERVER_HOME_DIRECTORY"; + 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 = {}; } public async connect(): Promise { @@ -82,16 +88,11 @@ class RestSASServerAdapter implements ContentAdapter { ): Promise { const response = await this.fileSystemApi.createFileOrDirectory({ sessionId: this.sessionId, - fileOrDirectoryPath: parentItem.uri.replace( - `/compute/sessions/${this.sessionId}/files/`, - "", - ), + fileOrDirectoryPath: this.trimComputePrefix(parentItem.uri), fileProperties: { name: folderName, isDirectory: true }, }); - return this.enrichWithDataProviderProperties( - this.filePropertiesToContentItem(response.data), - ); + return this.filePropertiesToContentItem(response.data); } public async createNewItem( @@ -101,18 +102,15 @@ class RestSASServerAdapter implements ContentAdapter { ): Promise { const response = await this.fileSystemApi.createFileOrDirectory({ sessionId: this.sessionId, - fileOrDirectoryPath: parentItem.uri.replace( - `/compute/sessions/${this.sessionId}/files/`, - "", - ), + fileOrDirectoryPath: this.trimComputePrefix(parentItem.uri), fileProperties: { name: fileName, isDirectory: false }, }); if (buffer) { const etag = response.headers.etag; - const filePath = getLink(response.data.links, "GET", "self").uri.replace( - `/compute/sessions/${this.sessionId}/files/`, - "", + // TODO (sas-server) This could be combined with update content most likely. + const filePath = this.trimComputePrefix( + getLink(response.data.links, "GET", "self").uri, ); await this.fileSystemApi.updateFileContentOnSystem({ sessionId: this.sessionId, @@ -122,9 +120,7 @@ class RestSASServerAdapter implements ContentAdapter { }); } - return this.enrichWithDataProviderProperties( - this.filePropertiesToContentItem(response.data), - ); + return this.filePropertiesToContentItem(response.data); } public async deleteItem(item: ContentItem): Promise { @@ -134,46 +130,32 @@ class RestSASServerAdapter implements ContentAdapter { public async getChildItems(parentItem: ContentItem): Promise { // If the user is fetching child items of the root folder, give them the // "home" directory - const id = "SAS_SERVER_HOME_DIRECTORY"; if (parentItem.id === SERVER_FOLDER_ID) { return [ - this.enrichWithDataProviderProperties({ - ...createStaticFolder( - id, + this.filePropertiesToContentItem( + createStaticFolder( + SAS_SERVER_HOME_DIRECTORY, "Home", "userRoot", `/compute/sessions/${this.sessionId}/files/~fs~/members`, "getDirectoryMembers", ), - creationTimeStamp: 0, - modifiedTimeStamp: 0, - permission: undefined, - }), + ), ]; } const { data } = await this.fileSystemApi.getDirectoryMembers({ sessionId: this.sessionId, - directoryPath: parseMemberUri( + directoryPath: this.trimComputePrefix( getLink(parentItem.links, "GET", "getDirectoryMembers").uri, - this.sessionId, - ), + ).replace("/members", ""), }); // TODO (sas-server) We need to paginate and sort results return data.items.map((childItem: FileProperties, index) => ({ ...this.filePropertiesToContentItem(childItem), uid: `${parentItem.uid}/${index}`, - ...this.enrichWithDataProviderProperties( - this.filePropertiesToContentItem(childItem), - ), })); - - function parseMemberUri(uri: string, sessionId: string): string { - return uri - .replace(`/compute/sessions/${sessionId}/files/`, "") - .replace("/members", ""); - } } public async getContentOfItem(item: ContentItem): Promise { @@ -181,13 +163,9 @@ class RestSASServerAdapter implements ContentAdapter { } public async getContentOfUri(uri: Uri): Promise { - // TODO (sas-server) We're using this a bunch. Make things more better-er - const path = getResourceId(uri).replace( - `/compute/sessions/${this.sessionId}/files/`, - "", - ); + const path = this.trimComputePrefix(getResourceId(uri)); - const { data } = await this.fileSystemApi.getFileContentFromSystem( + const response = await this.fileSystemApi.getFileContentFromSystem( { sessionId: this.sessionId, filePath: path, @@ -197,11 +175,13 @@ class RestSASServerAdapter implements ContentAdapter { }, ); + 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 data as unknown as string; + return response.data as unknown as string; } public async getFolderPathForItem(item: ContentItem): Promise { @@ -214,18 +194,13 @@ class RestSASServerAdapter implements ContentAdapter { public async getItemOfUri(uri: Uri): Promise { const resourceId = getResourceId(uri); + const { data } = await this.fileSystemApi.getFileorDirectoryProperties({ sessionId: this.sessionId, - // TODO (sas-server) cleanup/reuse this - fileOrDirectoryPath: resourceId.replace( - `/compute/sessions/${this.sessionId}/files/`, - "", - ), + fileOrDirectoryPath: this.trimComputePrefix(resourceId), }); - return this.enrichWithDataProviderProperties( - this.filePropertiesToContentItem(data), - ); + return this.filePropertiesToContentItem(data); } public async getParentOfItem( @@ -251,7 +226,7 @@ class RestSASServerAdapter implements ContentAdapter { this.rootFolders[delegateFolderName] = { ...result.data, uid: `${index}`, - ...this.enrichWithDataProviderProperties(result.data), + ...this.filePropertiesToContentItem(result.data), }; } @@ -293,7 +268,23 @@ class RestSASServerAdapter implements ContentAdapter { item: ContentItem, newName: string, ): Promise { - throw new Error("Method not implemented."); + const filePath = this.trimComputePrefix(item.uri); + + const isDirectory = item.fileStat?.type === FileType.Directory; + const parsedFilePath = filePath.split("~fs~"); + parsedFilePath.pop(); + const path = parsedFilePath.join("/"); + + const response = await this.fileSystemApi.updateFileOrDirectoryOnSystem({ + sessionId: this.sessionId, + fileOrDirectoryPath: filePath, + ifMatch: "", + fileProperties: { name: newName, path, isDirectory }, + }); + + this.updateFileMetadata(filePath, response); + + return this.filePropertiesToContentItem(response.data); } public async restoreItem(item: ContentItem): Promise { @@ -301,48 +292,26 @@ class RestSASServerAdapter implements ContentAdapter { } public async updateContentOfItem(uri: Uri, content: string): Promise { - throw new Error("Method not implemented."); - } + const filePath = this.trimComputePrefix(getResourceId(uri)); + const { etag } = this.getFileInfo(filePath); - private enrichWithDataProviderProperties( - item: ContentItem, - flags?: ContentItem["flags"], - ): ContentItem { - item.flags = flags; - return { - ...item, - permission: getPermission(item), - contextValue: resourceType(item), - fileStat: { - ctime: item.creationTimeStamp, - mtime: item.modifiedTimeStamp, - size: 0, - type: getIsContainer(item) ? FileType.Directory : FileType.File, - }, - isReference: isReference(item), - resourceId: getResourceIdFromItem(item), - vscUri: getSasServerUri(item, flags?.isInRecycleBin || false), - typeName: getTypeName(item), - }; + const response = await this.fileSystemApi.updateFileContentOnSystem({ + 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, + }); - function getIsContainer(item: ContentItem): boolean { - if (item.fileStat?.type === FileType.Directory) { - return true; - } - - const typeName = getTypeName(item); - if (isItemInRecycleBin(item) && isReference(item)) { - return false; - } - if (FOLDER_TYPES.indexOf(typeName) >= 0) { - return true; - } - return false; - } + this.updateFileMetadata(filePath, response); } private filePropertiesToContentItem( fileProperties: FileProperties, + flags?: ContentItem["flags"], ): ContentItem { const links = fileProperties.links.map((link) => ({ method: link.method, @@ -353,25 +322,65 @@ class RestSASServerAdapter implements ContentAdapter { })); const id = getLink(links, "GET", "self").uri; - return { + 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, - // These will be overwritten permission: { - write: false, - delete: false, - addMember: false, + write: !isRootFolder && !fileProperties.readOnly, + delete: !isRootFolder && !fileProperties.readOnly, + addMember: + !!getLink(links, "POST", "makeDirectory") || + !!getLink(links, "POST", "createFile"), }, + flags, + }; + + const typeName = getTypeName(item); + + return { + ...item, + contextValue: resourceType(item), fileStat: { - type: fileProperties.isDirectory ? FileType.Directory : FileType.File, - ctime: 0, - mtime: 0, + 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 { + return uri.replace(`/compute/sessions/${this.sessionId}/files/`, ""); + } + + private updateFileMetadata(id: string, { headers }: AxiosResponse) { + this.fileMetadataMap[id] = { + etag: headers.etag, + }; + } + + private getFileInfo(resourceId: string) { + if (resourceId in this.fileMetadataMap) { + return this.fileMetadataMap[resourceId]; + } + return { + etag: "", }; } } diff --git a/client/src/connection/rest/SASContentAdapter.ts b/client/src/connection/rest/SASContentAdapter.ts index 7a8323793..9a7a5941c 100644 --- a/client/src/connection/rest/SASContentAdapter.ts +++ b/client/src/connection/rest/SASContentAdapter.ts @@ -343,9 +343,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, diff --git a/client/src/connection/rest/util.ts b/client/src/connection/rest/util.ts index 07f7d70d9..cf23efc31 100644 --- a/client/src/connection/rest/util.ts +++ b/client/src/connection/rest/util.ts @@ -1,6 +1,6 @@ // Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { FileType, Uri } from "vscode"; +import { Uri } from "vscode"; import { DATAFLOW_TYPE, @@ -56,7 +56,7 @@ export const resourceType = (item: ContentItem): string | undefined => { return; } - const { write, delete: del, addMember } = getPermission(item); + const { write, delete: del, addMember } = item.permission; const isRecycled = isItemInRecycleBin(item); const actions = [ addMember && !isRecycled && "createChild", @@ -111,15 +111,11 @@ export const getSasServerUri = (item: ContentItem, readOnly?: boolean): Uri => export const getPermission = (item: ContentItem): Permission => { const itemType = getTypeName(item); - return [FOLDER_TYPE, ...FILE_TYPES].includes(itemType) || - item.fileStat?.type === FileType.Directory + return [FOLDER_TYPE, ...FILE_TYPES].includes(itemType) // normal folders and files ? { write: !!getLink(item.links, "PUT", "update"), delete: !!getLink(item.links, "DELETE", "deleteResource"), - addMember: - !!getLink(item.links, "POST", "createChild") || - !!getLink(item.links, "POST", "makeDirectory") || - !!getLink(item.links, "POST", "createFile"), + addMember: !!getLink(item.links, "POST", "createChild"), } : { // delegate folders, user folder and user root folder From 266ba45aed28cfc16e20b13600b984dea0343323 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 13 Sep 2024 14:42:42 -0400 Subject: [PATCH 05/30] chore: implement delete/change explorer -> sas content Signed-off-by: Scott Dover --- .../connection/rest/RestSASServerAdapter.ts | 19 +++++++++++++++++-- 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 | 2 +- package.nls.ko.json | 1 - package.nls.pt-br.json | 1 - package.nls.zh-cn.json | 1 - 10 files changed, 18 insertions(+), 11 deletions(-) diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 16fb6692b..24fb3c572 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -124,7 +124,18 @@ class RestSASServerAdapter implements ContentAdapter { } public async deleteItem(item: ContentItem): Promise { - throw new Error("di Method not implemented."); + const filePath = this.trimComputePrefix(item.uri); + try { + await this.fileSystemApi.deleteFileOrDirectoryFromSystem({ + sessionId: this.sessionId, + fileOrDirectoryPath: filePath, + ifMatch: "", + }); + delete this.fileMetadataMap[filePath]; + return true; + } catch (e) { + return false; + } } public async getChildItems(parentItem: ContentItem): Promise { @@ -257,7 +268,11 @@ class RestSASServerAdapter implements ContentAdapter { public async recycleItem( item: ContentItem, ): Promise<{ newUri?: Uri; oldUri?: Uri }> { - throw new Error("Method not implemented."); + await this.deleteItem(item); + return { + newUri: getSasServerUri(item, true), + oldUri: getSasServerUri(item), + }; } public async removeItemFromFavorites(item: ContentItem): Promise { 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 7e6184561..2f306485b 100644 --- a/package.nls.json +++ b/package.nls.json @@ -72,7 +72,7 @@ "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", 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 内容导航。您只能使用资源管理器视图访问文件。", From b9637161b6d929162304a94d2b66573776d8b1e1 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 20 Sep 2024 11:25:13 -0400 Subject: [PATCH 06/30] chore: implement move/paginate results/add error checking Signed-off-by: Scott Dover --- .../ContentNavigator/ContentDataProvider.ts | 2 +- .../connection/rest/RestSASServerAdapter.ts | 128 ++++++++++++++---- 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index 9f2557e4d..ec3e06669 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -501,7 +501,7 @@ class ContentDataProvider ): 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; diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 24fb3c572..65ff371af 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -54,7 +54,31 @@ class RestSASServerAdapter implements ContentAdapter { await session.setup(true); this.sessionId = session?.sessionId(); - this.fileSystemApi = FileSystemApi(getApiConfig()); + // This proxies all calls to the fileSystem api to reconnect + // if we ever get a 401 (unauthorized) + 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); + } catch (error) { + if (error.response?.status !== 401) { + throw error; + } + + await this.connect(); + + return await target(...argList); + } + }, + }); + } + + return target[property]; + }, + }); } public connected(): boolean { @@ -155,18 +179,42 @@ class RestSASServerAdapter implements ContentAdapter { ]; } - const { data } = await this.fileSystemApi.getDirectoryMembers({ - sessionId: this.sessionId, - directoryPath: this.trimComputePrefix( - getLink(parentItem.links, "GET", "getDirectoryMembers").uri, - ).replace("/members", ""), - }); + 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; - // TODO (sas-server) We need to paginate and sort results - return data.items.map((childItem: FileProperties, index) => ({ - ...this.filePropertiesToContentItem(childItem), - uid: `${parentItem.uid}/${index}`, - })); + 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 { @@ -204,14 +252,15 @@ class RestSASServerAdapter implements ContentAdapter { } public async getItemOfUri(uri: Uri): Promise { - const resourceId = getResourceId(uri); - - const { data } = await this.fileSystemApi.getFileorDirectoryProperties({ + const fileOrDirectoryPath = this.trimComputePrefix(getResourceId(uri)); + const response = await this.fileSystemApi.getFileorDirectoryProperties({ sessionId: this.sessionId, - fileOrDirectoryPath: this.trimComputePrefix(resourceId), + fileOrDirectoryPath, }); - return this.filePropertiesToContentItem(data); + this.updateFileMetadata(fileOrDirectoryPath, response); + + return this.filePropertiesToContentItem(response.data); } public async getParentOfItem( @@ -262,7 +311,25 @@ class RestSASServerAdapter implements ContentAdapter { item: ContentItem, targetParentFolderUri: string, ): Promise { - throw new Error("Method not implemented."); + const currentFilePath = this.trimComputePrefix(item.uri); + const newFilePath = this.trimComputePrefix(targetParentFolderUri); + const { etag } = await this.getFileInfo(currentFilePath); + const params = { + sessionId: this.sessionId, + fileOrDirectoryPath: currentFilePath, + ifMatch: etag, + fileProperties: { + name: item.name, + path: newFilePath.split("~fs~").join("/"), + }, + }; + + const response = + await this.fileSystemApi.updateFileOrDirectoryOnSystem(params); + delete this.fileMetadataMap[currentFilePath]; + this.updateFileMetadata(newFilePath, response); + + return !!this.filePropertiesToContentItem(response.data); } public async recycleItem( @@ -285,7 +352,6 @@ class RestSASServerAdapter implements ContentAdapter { ): Promise { const filePath = this.trimComputePrefix(item.uri); - const isDirectory = item.fileStat?.type === FileType.Directory; const parsedFilePath = filePath.split("~fs~"); parsedFilePath.pop(); const path = parsedFilePath.join("/"); @@ -294,7 +360,7 @@ class RestSASServerAdapter implements ContentAdapter { sessionId: this.sessionId, fileOrDirectoryPath: filePath, ifMatch: "", - fileProperties: { name: newName, path, isDirectory }, + fileProperties: { name: newName, path }, }); this.updateFileMetadata(filePath, response); @@ -308,7 +374,7 @@ class RestSASServerAdapter implements ContentAdapter { public async updateContentOfItem(uri: Uri, content: string): Promise { const filePath = this.trimComputePrefix(getResourceId(uri)); - const { etag } = this.getFileInfo(filePath); + const { etag } = await this.getFileInfo(filePath); const response = await this.fileSystemApi.updateFileContentOnSystem({ sessionId: this.sessionId, @@ -388,12 +454,26 @@ class RestSASServerAdapter implements ContentAdapter { this.fileMetadataMap[id] = { etag: headers.etag, }; + + return this.fileMetadataMap[id]; } - private getFileInfo(resourceId: string) { - if (resourceId in this.fileMetadataMap) { - return this.fileMetadataMap[resourceId]; + private async getFileInfo(path: string) { + if (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); + } catch (e) { + // Intentionally blank + } + return { etag: "", }; From 02475122a5693b76533d882a85c7b955d21a0756 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 20 Sep 2024 15:11:41 -0400 Subject: [PATCH 07/30] chore: implement upload/download/drag drop Signed-off-by: Scott Dover --- .../connection/rest/RestSASServerAdapter.ts | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 65ff371af..ec0029472 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -56,6 +56,7 @@ class RestSASServerAdapter implements ContentAdapter { this.sessionId = session?.sessionId(); // This proxies all calls to the fileSystem api to reconnect // if we ever get a 401 (unauthorized) + const reconnect = async () => await this.connect(); this.fileSystemApi = new Proxy(FileSystemApi(getApiConfig()), { get: function (target, property) { if (typeof target[property] === "function") { @@ -68,7 +69,7 @@ class RestSASServerAdapter implements ContentAdapter { throw error; } - await this.connect(); + await reconnect(); return await target(...argList); } @@ -130,21 +131,16 @@ class RestSASServerAdapter implements ContentAdapter { fileProperties: { name: fileName, isDirectory: false }, }); + const contentItem = this.filePropertiesToContentItem(response.data); + if (buffer) { - const etag = response.headers.etag; - // TODO (sas-server) This could be combined with update content most likely. - const filePath = this.trimComputePrefix( - getLink(response.data.links, "GET", "self").uri, + await this.updateContentOfItemAtPath( + this.trimComputePrefix(contentItem.uri), + new TextDecoder().decode(buffer), ); - await this.fileSystemApi.updateFileContentOnSystem({ - sessionId: this.sessionId, - filePath, - body: new File([buffer], response.data.name), - ifMatch: etag, - }); } - return this.filePropertiesToContentItem(response.data); + return contentItem; } public async deleteItem(item: ContentItem): Promise { @@ -218,12 +214,16 @@ class RestSASServerAdapter implements ContentAdapter { } public async getContentOfItem(item: ContentItem): Promise { - throw new Error("getContentOfItem"); + 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, @@ -313,7 +313,7 @@ class RestSASServerAdapter implements ContentAdapter { ): Promise { const currentFilePath = this.trimComputePrefix(item.uri); const newFilePath = this.trimComputePrefix(targetParentFolderUri); - const { etag } = await this.getFileInfo(currentFilePath); + const { etag } = await this.getFileInfo(currentFilePath, true); const params = { sessionId: this.sessionId, fileOrDirectoryPath: currentFilePath, @@ -374,9 +374,15 @@ class RestSASServerAdapter implements ContentAdapter { public async updateContentOfItem(uri: Uri, content: string): Promise { const filePath = this.trimComputePrefix(getResourceId(uri)); - const { etag } = await this.getFileInfo(filePath); + return await this.updateContentOfItemAtPath(filePath, content); + } - const response = await this.fileSystemApi.updateFileContentOnSystem({ + 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 @@ -385,7 +391,8 @@ class RestSASServerAdapter implements ContentAdapter { // 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); } @@ -402,6 +409,10 @@ class RestSASServerAdapter implements ContentAdapter { uri: link.uri, })); + if (!getLink(links, "GET", "self")) { + console.log("OH NBO"); + } + const id = getLink(links, "GET", "self").uri; const isRootFolder = [SERVER_FOLDER_ID, SAS_SERVER_HOME_DIRECTORY].includes( id, @@ -447,7 +458,9 @@ class RestSASServerAdapter implements ContentAdapter { } private trimComputePrefix(uri: string): string { - return uri.replace(`/compute/sessions/${this.sessionId}/files/`, ""); + return decodeURI( + uri.replace(`/compute/sessions/${this.sessionId}/files/`, ""), + ); } private updateFileMetadata(id: string, { headers }: AxiosResponse) { @@ -458,8 +471,8 @@ class RestSASServerAdapter implements ContentAdapter { return this.fileMetadataMap[id]; } - private async getFileInfo(path: string) { - if (path in this.fileMetadataMap) { + private async getFileInfo(path: string, forceRefresh?: boolean) { + if (!forceRefresh && path in this.fileMetadataMap) { return this.fileMetadataMap[path]; } From 321801e0f91c60821eeace862e61f5782edf68a8 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Mon, 23 Sep 2024 09:20:12 -0400 Subject: [PATCH 08/30] chore: add comments for unimplemented code Signed-off-by: Scott Dover --- .../src/components/ContentNavigator/types.ts | 1 - .../connection/rest/RestSASServerAdapter.ts | 20 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/client/src/components/ContentNavigator/types.ts b/client/src/components/ContentNavigator/types.ts index f42c55852..365b708d4 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; diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index ec0029472..99df4a765 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -83,7 +83,7 @@ class RestSASServerAdapter implements ContentAdapter { } public connected(): boolean { - // TODO + // TODO (sas-server) return true; } @@ -243,12 +243,10 @@ class RestSASServerAdapter implements ContentAdapter { return response.data as unknown as string; } - public async getFolderPathForItem(item: ContentItem): Promise { - throw new Error("getFolderPathForItem Method not implemented."); - } - - public async getItemOfId(id: string): Promise { - throw new Error("getItemOfId Method not implemented."); + 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 { @@ -266,11 +264,15 @@ class RestSASServerAdapter implements ContentAdapter { public async getParentOfItem( item: ContentItem, ): Promise { + // TODO (sas-server) This is needed for converting a sas + // notebook to flow. throw new Error("getParentOfItem Method not implemented."); } - public getRootFolder(name: string): ContentItem | undefined { - throw new Error("getRootFolder Method not implemented."); + public getRootFolder(): ContentItem | undefined { + // TODO (sas-server) Re-implement this if SAS server supports + // recycle bin + return undefined; } public async getRootItems(): Promise { From a59d2fb17c5689c47f045aa675ed76b509c34b4b Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 27 Sep 2024 15:14:33 -0400 Subject: [PATCH 09/30] chore: update notebook to flow functionality Signed-off-by: Scott Dover --- .../components/ContentNavigator/convert.ts | 33 +++++++++++-------- .../src/components/ContentNavigator/index.ts | 1 + .../connection/rest/RestSASServerAdapter.ts | 12 +++++-- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/client/src/components/ContentNavigator/convert.ts b/client/src/components/ContentNavigator/convert.ts index 8379ff721..be54ca318 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); @@ -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 82a2aaffc..c67af1cd6 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -281,6 +281,7 @@ class ContentNavigator implements SubscriptionProvider { resource, this.contentModel, this.viyaEndpoint(), + this.sourceType, ); const inputName = notebookToFlowConverter.inputName; diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 99df4a765..8a10d6fb5 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -264,9 +264,15 @@ class RestSASServerAdapter implements ContentAdapter { public async getParentOfItem( item: ContentItem, ): Promise { - // TODO (sas-server) This is needed for converting a sas - // notebook to flow. - throw new Error("getParentOfItem Method not implemented."); + const parentPathPieces = this.trimComputePrefix(item.uri).split("~fs~"); + parentPathPieces.pop(); + const fileOrDirectoryPath = parentPathPieces.join("~fs~"); + const response = await this.fileSystemApi.getFileorDirectoryProperties({ + sessionId: this.sessionId, + fileOrDirectoryPath, + }); + + return this.filePropertiesToContentItem(response.data); } public getRootFolder(): ContentItem | undefined { From d6c99337298b0ea327b41f12c17b896adbf80b11 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 27 Sep 2024 15:26:26 -0400 Subject: [PATCH 10/30] chore: fix copyright,tests Signed-off-by: Scott Dover --- client/src/connection/rest/RestSASServerAdapter.ts | 3 +++ .../components/ContentNavigator/ContentDataProvider.test.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 8a10d6fb5..8432a24ad 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -1,3 +1,6 @@ +// 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"; diff --git a/client/test/components/ContentNavigator/ContentDataProvider.test.ts b/client/test/components/ContentNavigator/ContentDataProvider.test.ts index fef0e5686..67dd50f11 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; From 8d1a6812bca69f7ff6def25557faece2157a86bd Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 27 Sep 2024 15:37:53 -0400 Subject: [PATCH 11/30] chore: disable recycle bin support for sas server Signed-off-by: Scott Dover --- .../ContentNavigator/ContentDataProvider.ts | 4 ++++ .../components/ContentNavigator/ContentModel.ts | 14 ++++++++++++-- client/src/components/ContentNavigator/index.ts | 4 ++-- client/src/components/ContentNavigator/types.ts | 4 ++-- .../src/connection/rest/RestSASServerAdapter.ts | 15 --------------- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index ec3e06669..f30755533 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -314,6 +314,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; diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 55f1b3536..d557fc849 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -4,6 +4,7 @@ import { Uri } from "vscode"; import { ALL_ROOT_FOLDERS, Messages } from "./const"; import { ContentAdapter, ContentItem } from "./types"; +import { isItemInRecycleBin } from "./utils"; export class ContentModel { private contentAdapter: ContentAdapter; @@ -141,11 +142,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/index.ts b/client/src/components/ContentNavigator/index.ts index c67af1cd6..7684924b3 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( @@ -93,7 +93,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( diff --git a/client/src/components/ContentNavigator/types.ts b/client/src/components/ContentNavigator/types.ts index 365b708d4..1068a322c 100644 --- a/client/src/components/ContentNavigator/types.ts +++ b/client/src/components/ContentNavigator/types.ts @@ -90,13 +90,13 @@ export interface ContentAdapter { item: ContentItem, targetParentFolderUri: string, ) => Promise; - recycleItem: (item: ContentItem) => Promise<{ newUri?: Uri; oldUri?: Uri }>; + 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/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 8432a24ad..bcce42909 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -1,6 +1,5 @@ // 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"; @@ -343,16 +342,6 @@ class RestSASServerAdapter implements ContentAdapter { return !!this.filePropertiesToContentItem(response.data); } - public async recycleItem( - item: ContentItem, - ): Promise<{ newUri?: Uri; oldUri?: Uri }> { - await this.deleteItem(item); - return { - newUri: getSasServerUri(item, true), - oldUri: getSasServerUri(item), - }; - } - public async removeItemFromFavorites(item: ContentItem): Promise { throw new Error("Method not implemented."); } @@ -379,10 +368,6 @@ class RestSASServerAdapter implements ContentAdapter { return this.filePropertiesToContentItem(response.data); } - public async restoreItem(item: ContentItem): Promise { - throw new Error("Method not implemented."); - } - public async updateContentOfItem(uri: Uri, content: string): Promise { const filePath = this.trimComputePrefix(getResourceId(uri)); return await this.updateContentOfItemAtPath(filePath, content); From 9e3dac8ec32603550beac8033e4fe566ecba6bf1 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 27 Sep 2024 15:54:38 -0400 Subject: [PATCH 12/30] chore: add icons Signed-off-by: Scott Dover --- .../ContentNavigator/ContentDataProvider.ts | 9 +++++++++ client/src/components/ContentNavigator/const.ts | 4 +++- client/src/connection/rest/RestSASServerAdapter.ts | 12 ++++-------- icons/dark/serverDark.svg | 8 ++++++++ icons/dark/userWorkspaceDark.svg | 4 ++++ icons/light/serverLight.svg | 8 ++++++++ icons/light/userWorkspaceLight.svg | 4 ++++ 7 files changed, 40 insertions(+), 9 deletions(-) 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 diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index f30755533..01bf6a183 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -45,6 +45,8 @@ import { FAVORITES_FOLDER_TYPE, Messages, ROOT_FOLDER_TYPE, + SERVER_HOME_FOLDER_TYPE, + SERVER_ROOT_FOLDER_TYPE, TRASH_FOLDER_TYPE, } from "./const"; import { @@ -641,6 +643,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; @@ -651,6 +659,7 @@ class ContentDataProvider icon = "sasProgramFile"; } } + return icon !== "" ? { dark: Uri.joinPath(this.extensionUri, `icons/dark/${icon}Dark.svg`), diff --git a/client/src/components/ContentNavigator/const.ts b/client/src/components/ContentNavigator/const.ts index 04335be93..fa5030a40 100644 --- a/client/src/components/ContentNavigator/const.ts +++ b/client/src/components/ContentNavigator/const.ts @@ -16,10 +16,12 @@ export const ROOT_FOLDER = createStaticFolder( ); 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", - ROOT_FOLDER_TYPE, + SERVER_ROOT_FOLDER_TYPE, "/", "getDirectoryMembers", ); diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index bcce42909..52f33f9bd 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -10,6 +10,7 @@ import { SAS_SERVER_ROOT_FOLDER, SAS_SERVER_ROOT_FOLDERS, SERVER_FOLDER_ID, + SERVER_HOME_FOLDER_TYPE, } from "../../components/ContentNavigator/const"; import { AddChildItemProperties, @@ -169,7 +170,7 @@ class RestSASServerAdapter implements ContentAdapter { createStaticFolder( SAS_SERVER_HOME_DIRECTORY, "Home", - "userRoot", + SERVER_HOME_FOLDER_TYPE, `/compute/sessions/${this.sessionId}/files/~fs~/members`, "getDirectoryMembers", ), @@ -278,8 +279,6 @@ class RestSASServerAdapter implements ContentAdapter { } public getRootFolder(): ContentItem | undefined { - // TODO (sas-server) Re-implement this if SAS server supports - // recycle bin return undefined; } @@ -394,7 +393,7 @@ class RestSASServerAdapter implements ContentAdapter { } private filePropertiesToContentItem( - fileProperties: FileProperties, + fileProperties: FileProperties & { type?: string }, flags?: ContentItem["flags"], ): ContentItem { const links = fileProperties.links.map((link) => ({ @@ -405,10 +404,6 @@ class RestSASServerAdapter implements ContentAdapter { uri: link.uri, })); - if (!getLink(links, "GET", "self")) { - console.log("OH NBO"); - } - const id = getLink(links, "GET", "self").uri; const isRootFolder = [SERVER_FOLDER_ID, SAS_SERVER_HOME_DIRECTORY].includes( id, @@ -428,6 +423,7 @@ class RestSASServerAdapter implements ContentAdapter { !!getLink(links, "POST", "createFile"), }, flags, + type: fileProperties.type || "", }; const typeName = getTypeName(item); 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 From 0d3da3811962c8a39b1e45496d873afc5153c743 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 4 Oct 2024 11:30:51 -0400 Subject: [PATCH 13/30] chore: adjust todos/comments Signed-off-by: Scott Dover --- .../ContentNavigator/ContentAdapterFactory.ts | 2 +- .../ContentNavigator/ContentModel.ts | 1 - .../src/components/ContentNavigator/const.ts | 6 +--- .../connection/rest/RestSASServerAdapter.ts | 28 ++++++++++--------- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentAdapterFactory.ts b/client/src/components/ContentNavigator/ContentAdapterFactory.ts index 3a3c207b7..d115067e9 100644 --- a/client/src/components/ContentNavigator/ContentAdapterFactory.ts +++ b/client/src/components/ContentNavigator/ContentAdapterFactory.ts @@ -10,7 +10,7 @@ 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"], diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index d557fc849..bf048f9ac 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -34,7 +34,6 @@ export class ContentModel { return Object.entries(await this.contentAdapter.getRootItems()) .sort( // sort the delegate folders as the order in the supportedDelegateFolders - // TODO MEEEE! (a, b) => ALL_ROOT_FOLDERS.indexOf(a[0]) - ALL_ROOT_FOLDERS.indexOf(b[0]), ) diff --git a/client/src/components/ContentNavigator/const.ts b/client/src/components/ContentNavigator/const.ts index fa5030a40..dcc9a977a 100644 --- a/client/src/components/ContentNavigator/const.ts +++ b/client/src/components/ContentNavigator/const.ts @@ -50,11 +50,7 @@ export const SAS_CONTENT_ROOT_FOLDERS = [ "@myRecycleBin", ]; -export const SAS_SERVER_ROOT_FOLDERS = [ - // "@myFavorites", - "@sasServerRoot", - // "@myRecycleBin", -]; +export const SAS_SERVER_ROOT_FOLDERS = ["@sasServerRoot"]; export const ALL_ROOT_FOLDERS = [ ...SAS_CONTENT_ROOT_FOLDERS, diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 52f33f9bd..4e7cedc12 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -49,6 +49,13 @@ class RestSASServerAdapter implements ContentAdapter { 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; public async connect(): Promise { const session = getSession(); @@ -86,8 +93,7 @@ class RestSASServerAdapter implements ContentAdapter { } public connected(): boolean { - // TODO (sas-server) - return true; + return !!this.sessionId; } public async setup(): Promise { @@ -98,16 +104,14 @@ class RestSASServerAdapter implements ContentAdapter { await this.connect(); } - public async addChildItem( - childItemUri: string | undefined, - parentItemUri: string | undefined, - properties: AddChildItemProperties, - ): Promise { + // TODO #417 Implement favorites + public async addItemToFavorites(): Promise { throw new Error("Method not implemented."); } - public async addItemToFavorites(item: ContentItem): Promise { - throw new Error("fds Method not implemented."); + // TODO #417 Implement favorites + public async removeItemFromFavorites(): Promise { + throw new Error("Method not implemented."); } public async createNewFolder( @@ -278,6 +282,7 @@ class RestSASServerAdapter implements ContentAdapter { return this.filePropertiesToContentItem(response.data); } + // TODO #417 Implement as part of favorites public getRootFolder(): ContentItem | undefined { return undefined; } @@ -308,6 +313,7 @@ class RestSASServerAdapter implements ContentAdapter { } 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; @@ -341,10 +347,6 @@ class RestSASServerAdapter implements ContentAdapter { return !!this.filePropertiesToContentItem(response.data); } - public async removeItemFromFavorites(item: ContentItem): Promise { - throw new Error("Method not implemented."); - } - public async renameItem( item: ContentItem, newName: string, From 2287ef3f997236f617d0e06054a40c7bcb29a250 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 4 Oct 2024 14:20:18 -0400 Subject: [PATCH 14/30] fix: fix move issue Signed-off-by: Scott Dover --- .../ContentNavigator/ContentDataProvider.ts | 44 ++++++++++++------- .../ContentNavigator/ContentModel.ts | 2 +- .../src/components/ContentNavigator/types.ts | 2 +- .../src/components/ContentNavigator/utils.ts | 20 ++++++++- .../connection/rest/RestSASServerAdapter.ts | 4 +- .../src/connection/rest/SASContentAdapter.ts | 8 ++-- 6 files changed, 55 insertions(+), 25 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index 01bf6a183..acb4fff36 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -14,9 +14,6 @@ import { FileType, Position, ProviderResult, - Tab, - TabInputNotebook, - TabInputText, TextDocument, TextDocumentContentProvider, ThemeIcon, @@ -54,7 +51,11 @@ import { ContentNavigatorConfig, FileManipulationEvent, } from "./types"; -import { getFileStatement, isContainer as getIsContainer } from "./utils"; +import { + getEditorTabForItem, + getFileStatement, + isContainer as getIsContainer, +} from "./utils"; class ContentDataProvider implements @@ -501,6 +502,27 @@ 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, @@ -517,10 +539,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(); } @@ -675,14 +694,7 @@ 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 - ); + const tab = getEditorTabForItem(item); if (tab) { return window.tabGroups.close(tab); } diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index bf048f9ac..8a939cac6 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -129,7 +129,7 @@ export class ContentModel { public async moveTo( item: ContentItem, targetParentFolderUri: string, - ): Promise { + ): Promise { return await this.contentAdapter.moveItem(item, targetParentFolderUri); } diff --git a/client/src/components/ContentNavigator/types.ts b/client/src/components/ContentNavigator/types.ts index 1068a322c..15508f73f 100644 --- a/client/src/components/ContentNavigator/types.ts +++ b/client/src/components/ContentNavigator/types.ts @@ -89,7 +89,7 @@ export interface ContentAdapter { moveItem: ( item: ContentItem, targetParentFolderUri: string, - ) => Promise; + ) => Promise; recycleItem?: (item: ContentItem) => Promise<{ newUri?: Uri; oldUri?: Uri }>; removeItemFromFavorites: (item: ContentItem) => Promise; renameItem: ( diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index ee60f2736..3e72bbe4b 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"; @@ -76,3 +83,14 @@ export const createStaticFolder = ( }, ], }); + +export const getEditorTabForItem = (item: ContentItem) => { + const fileUri = item.vscUri; + const tabs: Tab[] = window.tabGroups.all.map((tg) => tg.tabs).flat(); + return tabs.find( + (tab) => + (tab.input instanceof TabInputText || + tab.input instanceof TabInputNotebook) && + tab.input.uri.query === fileUri.query, // compare the file id + ); +}; diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 4e7cedc12..d5460d359 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -325,7 +325,7 @@ class RestSASServerAdapter implements ContentAdapter { public async moveItem( item: ContentItem, targetParentFolderUri: string, - ): Promise { + ): Promise { const currentFilePath = this.trimComputePrefix(item.uri); const newFilePath = this.trimComputePrefix(targetParentFolderUri); const { etag } = await this.getFileInfo(currentFilePath, true); @@ -344,7 +344,7 @@ class RestSASServerAdapter implements ContentAdapter { delete this.fileMetadataMap[currentFilePath]; this.updateFileMetadata(newFilePath, response); - return !!this.filePropertiesToContentItem(response.data); + return this.filePropertiesToContentItem(response.data).vscUri; } public async renameItem( diff --git a/client/src/connection/rest/SASContentAdapter.ts b/client/src/connection/rest/SASContentAdapter.ts index 9a7a5941c..755aba31f 100644 --- a/client/src/connection/rest/SASContentAdapter.ts +++ b/client/src/connection/rest/SASContentAdapter.ts @@ -188,16 +188,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( From bdcc4d61dd5e3fbdf303f9f33291fb130f2b001a Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 4 Oct 2024 14:36:17 -0400 Subject: [PATCH 15/30] fix: type errors Signed-off-by: Scott Dover --- client/src/connection/rest/SASContentAdapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/connection/rest/SASContentAdapter.ts b/client/src/connection/rest/SASContentAdapter.ts index 755aba31f..b2340f92c 100644 --- a/client/src/connection/rest/SASContentAdapter.ts +++ b/client/src/connection/rest/SASContentAdapter.ts @@ -570,7 +570,7 @@ class SASContentAdapter implements ContentAdapter { } const success = await this.moveItem(item, recycleBinUri); - return recycleItemResponse(success); + return recycleItemResponse(!!success); function recycleItemResponse(success: boolean) { if (!success) { @@ -589,7 +589,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 { From 7809320f5ad472ee6118663b29ef8aad709600cc Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 4 Oct 2024 16:10:16 -0400 Subject: [PATCH 16/30] chore: fix mixed download files/folders Signed-off-by: Scott Dover --- .../connection/rest/RestSASServerAdapter.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index d5460d359..101cbd16e 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -35,6 +35,7 @@ import { } from "./util"; const SAS_SERVER_HOME_DIRECTORY = "SAS_SERVER_HOME_DIRECTORY"; +const SAS_FILE_SEPARATOR = "~fs~"; class RestSASServerAdapter implements ContentAdapter { protected baseUrl: string; @@ -93,7 +94,7 @@ class RestSASServerAdapter implements ContentAdapter { } public connected(): boolean { - return !!this.sessionId; + return true; } public async setup(): Promise { @@ -271,9 +272,9 @@ class RestSASServerAdapter implements ContentAdapter { public async getParentOfItem( item: ContentItem, ): Promise { - const parentPathPieces = this.trimComputePrefix(item.uri).split("~fs~"); - parentPathPieces.pop(); - const fileOrDirectoryPath = parentPathPieces.join("~fs~"); + const fileOrDirectoryPath = this.getParentPathOfUri( + this.trimComputePrefix(item.uri), + ); const response = await this.fileSystemApi.getFileorDirectoryProperties({ sessionId: this.sessionId, fileOrDirectoryPath, @@ -335,7 +336,7 @@ class RestSASServerAdapter implements ContentAdapter { ifMatch: etag, fileProperties: { name: item.name, - path: newFilePath.split("~fs~").join("/"), + path: newFilePath.split(SAS_FILE_SEPARATOR).join("/"), }, }; @@ -353,7 +354,7 @@ class RestSASServerAdapter implements ContentAdapter { ): Promise { const filePath = this.trimComputePrefix(item.uri); - const parsedFilePath = filePath.split("~fs~"); + const parsedFilePath = filePath.split(SAS_FILE_SEPARATOR); parsedFilePath.pop(); const path = parsedFilePath.join("/"); @@ -394,6 +395,12 @@ class RestSASServerAdapter implements ContentAdapter { 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"], @@ -426,6 +433,7 @@ class RestSASServerAdapter implements ContentAdapter { }, flags, type: fileProperties.type || "", + parentFolderUri: this.getParentPathOfUri(id), }; const typeName = getTypeName(item); From ddef0305ebe27b25b4d35482ae18bb5e22a1709f Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 11 Oct 2024 15:03:11 -0400 Subject: [PATCH 17/30] chore: fix session issues Signed-off-by: Scott Dover --- .../src/components/ContentNavigator/index.ts | 18 +++++++++---- .../connection/rest/RestSASServerAdapter.ts | 27 ++++++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/client/src/components/ContentNavigator/index.ts b/client/src/components/ContentNavigator/index.ts index 7684924b3..8b5ea59ad 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -269,11 +269,10 @@ class ContentNavigator implements SubscriptionProvider { ); }, ), - commands.registerCommand(`${SAS}.collapseAllContent`, () => { - commands.executeCommand( - `workbench.actions.treeView.${this.treeIdentifier}.collapseAll`, - ); - }), + commands.registerCommand( + `${SAS}.collapseAllContent`, + this.collapseAllContent, + ), commands.registerCommand( `${SAS}.convertNotebookToFlow`, async (resource: ContentItem | Uri) => { @@ -386,8 +385,11 @@ class ContentNavigator implements SubscriptionProvider { async (event: ConfigurationChangeEvent) => { if (event.affectsConfiguration("SAS.connectionProfiles")) { const endpoint = this.viyaEndpoint(); + this.collapseAllContent(); if (endpoint) { await this.contentDataProvider.connect(endpoint); + } else { + await this.contentDataProvider.refresh(); } } }, @@ -395,6 +397,12 @@ class ContentNavigator implements SubscriptionProvider { ]; } + private collapseAllContent() { + commands.executeCommand( + `workbench.actions.treeView.${this.treeIdentifier}.collapseAll`, + ); + } + private async uploadResource( resource: ContentItem, openDialogOptions: Partial = {}, diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 101cbd16e..b42f50254 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -58,16 +58,22 @@ class RestSASServerAdapter implements ContentAdapter { recycleItem?: (item: ContentItem) => Promise<{ newUri?: Uri; oldUri?: Uri }>; restoreItem?: (item: ContentItem) => Promise; - public async connect(): 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 () => await this.connect(); + const reconnect = async () => { + return await this.establishConnection(); + }; this.fileSystemApi = new Proxy(FileSystemApi(getApiConfig()), { get: function (target, property) { if (typeof target[property] === "function") { @@ -76,11 +82,14 @@ class RestSASServerAdapter implements ContentAdapter { try { return await target(...argList); } catch (error) { - if (error.response?.status !== 401) { - throw 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(); - 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); } @@ -461,7 +470,7 @@ class RestSASServerAdapter implements ContentAdapter { private trimComputePrefix(uri: string): string { return decodeURI( - uri.replace(`/compute/sessions/${this.sessionId}/files/`, ""), + uri.replace(/\/compute\/sessions\/[a-zA-Z0-9-]*\/files\//, ""), ); } From b9bb3a9e30c843febfeaa62a93f7908c58c27b06 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 25 Oct 2024 12:26:46 -0400 Subject: [PATCH 18/30] fix: fix collapse all Signed-off-by: Scott Dover --- client/src/components/ContentNavigator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/ContentNavigator/index.ts b/client/src/components/ContentNavigator/index.ts index 8b5ea59ad..f97b7a97c 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -271,7 +271,7 @@ class ContentNavigator implements SubscriptionProvider { ), commands.registerCommand( `${SAS}.collapseAllContent`, - this.collapseAllContent, + this.collapseAllContent.bind(this), ), commands.registerCommand( `${SAS}.convertNotebookToFlow`, From d40646201e69b851dae79969fe703aa3205529a9 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 25 Oct 2024 14:08:19 -0400 Subject: [PATCH 19/30] fix: fix sas content showing up in server Signed-off-by: Scott Dover --- .../src/components/ContentNavigator/ContentDataProvider.ts | 6 +++++- client/src/components/ContentNavigator/index.ts | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index acb4fff36..3de1ab966 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -71,7 +71,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; @@ -117,6 +117,10 @@ class ContentDataProvider }); } + public useModel(contentModel: ContentModel) { + this.model = contentModel; + } + public async handleDrop( target: ContentItem, sources: DataTransfer, diff --git a/client/src/components/ContentNavigator/index.ts b/client/src/components/ContentNavigator/index.ts index f97b7a97c..cc6310e8e 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -386,6 +386,9 @@ class ContentNavigator implements SubscriptionProvider { if (event.affectsConfiguration("SAS.connectionProfiles")) { const endpoint = this.viyaEndpoint(); this.collapseAllContent(); + this.contentDataProvider.useModel( + new ContentModel(this.contentAdapterForConnectionType()), + ); if (endpoint) { await this.contentDataProvider.connect(endpoint); } else { From 341be17f2595e7fb360523673e61a797757c4dad Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 25 Oct 2024 14:22:04 -0400 Subject: [PATCH 20/30] chore: add documentation Signed-off-by: Scott Dover --- CHANGELOG.md | 6 ++++++ website/docs/Features/accessServer.md | 23 +++++++++++++++++++++++ website/docs/matrix.md | 1 + 3 files changed, 30 insertions(+) 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/website/docs/Features/accessServer.md b/website/docs/Features/accessServer.md new file mode 100644 index 000000000..d3d6d7701 --- /dev/null +++ b/website/docs/Features/accessServer.md @@ -0,0 +1,23 @@ +--- +sidebar_position: 1 +--- + +# 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/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: | From bafebe35f603b90e56f0c40d792be484cd28fdb6 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 25 Oct 2024 15:47:43 -0400 Subject: [PATCH 21/30] fix: fix file name parsing/folder errors Signed-off-by: Scott Dover --- .../connection/rest/RestSASServerAdapter.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index b42f50254..0466e8a0a 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -128,13 +128,17 @@ class RestSASServerAdapter implements ContentAdapter { parentItem: ContentItem, folderName: string, ): Promise { - const response = await this.fileSystemApi.createFileOrDirectory({ - sessionId: this.sessionId, - fileOrDirectoryPath: this.trimComputePrefix(parentItem.uri), - fileProperties: { name: folderName, isDirectory: true }, - }); + 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); + return this.filePropertiesToContentItem(response.data); + } catch (error) { + return; + } } public async createNewItem( @@ -469,9 +473,15 @@ class RestSASServerAdapter implements ContentAdapter { } private trimComputePrefix(uri: string): string { - return decodeURI( - uri.replace(/\/compute\/sessions\/[a-zA-Z0-9-]*\/files\//, ""), + const uriWithoutPrefix = uri.replace( + /\/compute\/sessions\/[a-zA-Z0-9-]*\/files\//, + "", ); + try { + return decodeURIComponent(uriWithoutPrefix); + } catch (e) { + return uriWithoutPrefix; + } } private updateFileMetadata(id: string, { headers }: AxiosResponse) { From 039f860f33a56588a90d3607b4d24bbb51fdd52c Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Mon, 28 Oct 2024 09:06:28 -0400 Subject: [PATCH 22/30] chore: update sidebar position Signed-off-by: Scott Dover --- website/docs/Features/accessServer.md | 2 +- website/docs/Features/sasCodeEditing.md | 2 +- website/docs/Features/sasNotebook.md | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/website/docs/Features/accessServer.md b/website/docs/Features/accessServer.md index d3d6d7701..2b8c4847f 100644 --- a/website/docs/Features/accessServer.md +++ b/website/docs/Features/accessServer.md @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +sidebar_position: 3 --- # Accessing SAS Server diff --git a/website/docs/Features/sasCodeEditing.md b/website/docs/Features/sasCodeEditing.md index bf7fb2e1d..5a8a080e4 100644 --- a/website/docs/Features/sasCodeEditing.md +++ b/website/docs/Features/sasCodeEditing.md @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 6 --- # SAS Code Editing Features diff --git a/website/docs/Features/sasNotebook.md b/website/docs/Features/sasNotebook.md index f87d1e985..ab8b3b05a 100644 --- a/website/docs/Features/sasNotebook.md +++ b/website/docs/Features/sasNotebook.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 7 +--- + # SAS Notebook SAS Notebook is an interactive notebook file that includes markdown code, executable code snippets, and corresponding rich output cells. From 5aa6f97a9b691f01267e3b38911b00822c154325 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Mon, 28 Oct 2024 09:26:47 -0400 Subject: [PATCH 23/30] chore: update folder validation Signed-off-by: Scott Dover --- .../src/components/ContentNavigator/const.ts | 2 +- .../src/components/ContentNavigator/index.ts | 18 +++++++++++++++--- 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 - 10 files changed, 16 insertions(+), 12 deletions(-) diff --git a/client/src/components/ContentNavigator/const.ts b/client/src/components/ContentNavigator/const.ts index dcc9a977a..7c6e6c752 100644 --- a/client/src/components/ContentNavigator/const.ts +++ b/client/src/components/ContentNavigator/const.ts @@ -81,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/index.ts b/client/src/components/ContentNavigator/index.ts index cc6310e8e..54b6849d8 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -47,8 +47,19 @@ 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; @@ -187,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; 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.": "笔记本文件不包含任何要转换的代码。", From 8b3e24c94f3fe6e77dddebeb1f2a2a9250b28d84 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Mon, 28 Oct 2024 10:32:41 -0400 Subject: [PATCH 24/30] fix: fix file creation error/notebook naming Signed-off-by: Scott Dover --- .../ContentNavigator/ContentModel.ts | 41 ++++++++++++++++++- .../components/ContentNavigator/convert.ts | 2 +- .../connection/rest/RestSASServerAdapter.ts | 30 ++++++++------ 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 8a939cac6..288d05c58 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -1,6 +1,8 @@ // 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 { extname } from "path"; import { ALL_ROOT_FOLDERS, Messages } from "./const"; import { ContentAdapter, ContentItem } from "./types"; @@ -92,6 +94,43 @@ 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}.flw", { + basename, + number: number++, + }); + } while (usedFlowNames[newFileName]); + + return newFileName || fileName; + } + } + public async createFolder( item: ContentItem, name: string, diff --git a/client/src/components/ContentNavigator/convert.ts b/client/src/components/ContentNavigator/convert.ts index be54ca318..4a8783e2a 100644 --- a/client/src/components/ContentNavigator/convert.ts +++ b/client/src/components/ContentNavigator/convert.ts @@ -411,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, diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 0466e8a0a..b5e6e31c0 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -146,22 +146,26 @@ class RestSASServerAdapter implements ContentAdapter { fileName: string, buffer?: ArrayBufferLike, ): Promise { - const response = await this.fileSystemApi.createFileOrDirectory({ - sessionId: this.sessionId, - fileOrDirectoryPath: this.trimComputePrefix(parentItem.uri), - fileProperties: { name: fileName, isDirectory: false }, - }); + 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); + const contentItem = this.filePropertiesToContentItem(response.data); - if (buffer) { - await this.updateContentOfItemAtPath( - this.trimComputePrefix(contentItem.uri), - new TextDecoder().decode(buffer), - ); - } + if (buffer) { + await this.updateContentOfItemAtPath( + this.trimComputePrefix(contentItem.uri), + new TextDecoder().decode(buffer), + ); + } - return contentItem; + return contentItem; + } catch (error) { + return; + } } public async deleteItem(item: ContentItem): Promise { From d040a77a336f8898b4453c2276470f9531b2fe06 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Tue, 29 Oct 2024 09:07:12 -0400 Subject: [PATCH 25/30] fix: folder validation error Signed-off-by: Scott Dover --- client/src/components/ContentNavigator/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/components/ContentNavigator/index.ts b/client/src/components/ContentNavigator/index.ts index 54b6849d8..566b59ccd 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -227,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; From 5f8b69a50019e78c0880b57f12a401c521067ace Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 8 Nov 2024 14:27:52 -0500 Subject: [PATCH 26/30] chore: address code review comments Signed-off-by: Scott Dover --- .../ContentNavigator/ContentDataProvider.ts | 87 +++++++++++++++---- .../src/components/ContentNavigator/index.ts | 12 ++- .../src/components/ContentNavigator/utils.ts | 6 +- .../connection/rest/RestSASServerAdapter.ts | 5 ++ 4 files changed, 85 insertions(+), 25 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index 3de1ab966..5c525af6e 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -14,6 +14,8 @@ import { FileType, Position, ProviderResult, + TabInputNotebook, + TabInputText, TextDocument, TextDocumentContentProvider, ThemeIcon, @@ -52,7 +54,7 @@ import { FileManipulationEvent, } from "./types"; import { - getEditorTabForItem, + getEditorTabsForItem, getFileStatement, isContainer as getIsContainer, } from "./utils"; @@ -285,23 +287,62 @@ 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) { + commands.executeCommand("vscode.open", newUri); + } + const urisToOpen = getPreviouslyOpenedChildItems( + await this.getChildren(newItem), + ); + urisToOpen.forEach(([, newUri]) => + commands.executeCommand("vscode.open", newUri), + ); + 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; } } @@ -697,10 +738,26 @@ class ContentDataProvider export default ContentDataProvider; -const closeFileIfOpen = (item: ContentItem) => { - const tab = getEditorTabForItem(item); - 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/index.ts b/client/src/components/ContentNavigator/index.ts index 566b59ccd..9d187b486 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -63,16 +63,16 @@ const folderValidator = ( class ContentNavigator implements SubscriptionProvider { private contentDataProvider: ContentDataProvider; - private contentModel: ContentModel; private sourceType: ContentNavigatorConfig["sourceType"]; private treeIdentifier: ContentNavigatorConfig["treeIdentifier"]; + get contentModel() { + return new ContentModel(this.contentAdapterForConnectionType()); + } + constructor(context: ExtensionContext, config: ContentNavigatorConfig) { this.sourceType = config.sourceType; this.treeIdentifier = config.treeIdentifier; - this.contentModel = new ContentModel( - this.contentAdapterForConnectionType(), - ); this.contentDataProvider = new ContentDataProvider( this.contentModel, context.extensionUri, @@ -400,9 +400,7 @@ class ContentNavigator implements SubscriptionProvider { if (event.affectsConfiguration("SAS.connectionProfiles")) { const endpoint = this.viyaEndpoint(); this.collapseAllContent(); - this.contentDataProvider.useModel( - new ContentModel(this.contentAdapterForConnectionType()), - ); + this.contentDataProvider.useModel(this.contentModel); if (endpoint) { await this.contentDataProvider.connect(endpoint); } else { diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index 3e72bbe4b..2defeec3d 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -84,13 +84,13 @@ export const createStaticFolder = ( ], }); -export const getEditorTabForItem = (item: ContentItem) => { +export const getEditorTabsForItem = (item: ContentItem) => { const fileUri = item.vscUri; const tabs: Tab[] = window.tabGroups.all.map((tg) => tg.tabs).flat(); - return tabs.find( + return tabs.filter( (tab) => (tab.input instanceof TabInputText || tab.input instanceof TabInputNotebook) && - tab.input.uri.query === fileUri.query, // compare the file id + 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 index b5e6e31c0..499706bee 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -154,6 +154,10 @@ class RestSASServerAdapter implements ContentAdapter { }); const contentItem = this.filePropertiesToContentItem(response.data); + this.updateFileMetadata( + this.trimComputePrefix(contentItem.uri), + response, + ); if (buffer) { await this.updateContentOfItemAtPath( @@ -249,6 +253,7 @@ class RestSASServerAdapter implements ContentAdapter { } private async getContentOfItemAtPath(path: string) { + await this.setup(); const response = await this.fileSystemApi.getFileContentFromSystem( { sessionId: this.sessionId, From 7115d207195930a7c0bf54a46e44af313ec1c4f3 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Mon, 11 Nov 2024 15:39:12 -0500 Subject: [PATCH 27/30] fix: fix remaining issues Signed-off-by: Scott Dover --- .../ContentNavigator/ContentModel.ts | 3 ++- .../src/components/ContentNavigator/index.ts | 25 +++++++++++++------ .../connection/rest/RestSASServerAdapter.ts | 13 +++++++--- website/docs/Features/sasCodeEditing.md | 2 +- website/docs/Features/sasNotebook.md | 2 +- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 288d05c58..47e9fa0bf 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -121,9 +121,10 @@ export class ContentModel { let number = 1; let newFileName; do { - newFileName = l10n.t("{basename}_Copy{number}.flw", { + newFileName = l10n.t("{basename}_Copy{number}{ext}", { basename, number: number++, + ext, }); } while (usedFlowNames[newFileName]); diff --git a/client/src/components/ContentNavigator/index.ts b/client/src/components/ContentNavigator/index.ts index 9d187b486..4818f0545 100644 --- a/client/src/components/ContentNavigator/index.ts +++ b/client/src/components/ContentNavigator/index.ts @@ -63,16 +63,16 @@ const folderValidator = ( class ContentNavigator implements SubscriptionProvider { private contentDataProvider: ContentDataProvider; + private contentModel: ContentModel; private sourceType: ContentNavigatorConfig["sourceType"]; private treeIdentifier: ContentNavigatorConfig["treeIdentifier"]; - get contentModel() { - return new ContentModel(this.contentAdapterForConnectionType()); - } - constructor(context: ExtensionContext, config: ContentNavigatorConfig) { this.sourceType = config.sourceType; this.treeIdentifier = config.treeIdentifier; + this.contentModel = new ContentModel( + this.contentAdapterForConnectionType(), + ); this.contentDataProvider = new ContentDataProvider( this.contentModel, context.extensionUri, @@ -290,6 +290,7 @@ class ContentNavigator implements SubscriptionProvider { commands.registerCommand( `${SAS}.convertNotebookToFlow`, async (resource: ContentItem | Uri) => { + await this.contentModel.connect(this.viyaEndpoint()); const notebookToFlowConverter = new NotebookToFlowConverter( resource, this.contentModel, @@ -400,7 +401,11 @@ class ContentNavigator implements SubscriptionProvider { if (event.affectsConfiguration("SAS.connectionProfiles")) { const endpoint = this.viyaEndpoint(); this.collapseAllContent(); - this.contentDataProvider.useModel(this.contentModel); + const contentModel = new ContentModel( + this.contentAdapterForConnectionType(), + ); + this.contentDataProvider.useModel(contentModel); + this.contentModel = contentModel; if (endpoint) { await this.contentDataProvider.connect(endpoint); } else { @@ -412,10 +417,14 @@ class ContentNavigator implements SubscriptionProvider { ]; } - private collapseAllContent() { - commands.executeCommand( - `workbench.actions.treeView.${this.treeIdentifier}.collapseAll`, + 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( diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index 499706bee..fb672d774 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -81,6 +81,7 @@ class RestSASServerAdapter implements ContentAdapter { 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 @@ -136,6 +137,7 @@ class RestSASServerAdapter implements ContentAdapter { }); return this.filePropertiesToContentItem(response.data); + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { return; } @@ -167,6 +169,7 @@ class RestSASServerAdapter implements ContentAdapter { } return contentItem; + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { return; } @@ -182,7 +185,8 @@ class RestSASServerAdapter implements ContentAdapter { }); delete this.fileMetadataMap[filePath]; return true; - } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { return false; } } @@ -253,7 +257,6 @@ class RestSASServerAdapter implements ContentAdapter { } private async getContentOfItemAtPath(path: string) { - await this.setup(); const response = await this.fileSystemApi.getFileContentFromSystem( { sessionId: this.sessionId, @@ -488,7 +491,8 @@ class RestSASServerAdapter implements ContentAdapter { ); try { return decodeURIComponent(uriWithoutPrefix); - } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { return uriWithoutPrefix; } } @@ -513,7 +517,8 @@ class RestSASServerAdapter implements ContentAdapter { fileOrDirectoryPath: path, }); return this.updateFileMetadata(path, response); - } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { // Intentionally blank } diff --git a/website/docs/Features/sasCodeEditing.md b/website/docs/Features/sasCodeEditing.md index 5a8a080e4..3f5423230 100644 --- a/website/docs/Features/sasCodeEditing.md +++ b/website/docs/Features/sasCodeEditing.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 --- # SAS Code Editing Features diff --git a/website/docs/Features/sasNotebook.md b/website/docs/Features/sasNotebook.md index ab8b3b05a..f07d71ba5 100644 --- a/website/docs/Features/sasNotebook.md +++ b/website/docs/Features/sasNotebook.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 --- # SAS Notebook From afad7aac6521c9fb62d52572af4da31b07958916 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Thu, 14 Nov 2024 15:03:55 -0500 Subject: [PATCH 28/30] chore: update test coverage Signed-off-by: Scott Dover --- .../src/connection/rest/SASContentAdapter.ts | 1 + .../ContentDataProvider.test.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/client/src/connection/rest/SASContentAdapter.ts b/client/src/connection/rest/SASContentAdapter.ts index b2340f92c..0a6b85a92 100644 --- a/client/src/connection/rest/SASContentAdapter.ts +++ b/client/src/connection/rest/SASContentAdapter.ts @@ -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(); } diff --git a/client/test/components/ContentNavigator/ContentDataProvider.test.ts b/client/test/components/ContentNavigator/ContentDataProvider.test.ts index 67dd50f11..4caa8fc65 100644 --- a/client/test/components/ContentNavigator/ContentDataProvider.test.ts +++ b/client/test/components/ContentNavigator/ContentDataProvider.test.ts @@ -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( From f93ced33ad1a6fa5dc2403078929d5cda540576a Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Fri, 15 Nov 2024 09:43:14 -0500 Subject: [PATCH 29/30] fix: fix rename issues Signed-off-by: Scott Dover --- .../ContentNavigator/ContentDataProvider.ts | 22 ++++++++++++------- .../connection/rest/RestSASServerAdapter.ts | 21 +++++++++++------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index 5c525af6e..f6c1afe97 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -301,15 +301,21 @@ class ContentDataProvider const oldUriToNewUriMap = [[item.vscUri, newUri]]; const newItemIsContainer = getIsContainer(newItem); if (closing !== true && !newItemIsContainer) { - commands.executeCommand("vscode.open", newUri); + 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); } - const urisToOpen = getPreviouslyOpenedChildItems( - await this.getChildren(newItem), - ); - urisToOpen.forEach(([, newUri]) => - commands.executeCommand("vscode.open", newUri), - ); - oldUriToNewUriMap.push(...urisToOpen); oldUriToNewUriMap.forEach(([uri, newUri]) => this._onDidManipulateFile.fire({ type: "rename", diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index fb672d774..f609988bd 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -383,16 +383,21 @@ class RestSASServerAdapter implements ContentAdapter { parsedFilePath.pop(); const path = parsedFilePath.join("/"); - const response = await this.fileSystemApi.updateFileOrDirectoryOnSystem({ - sessionId: this.sessionId, - fileOrDirectoryPath: filePath, - ifMatch: "", - fileProperties: { name: newName, path }, - }); + try { + const response = await this.fileSystemApi.updateFileOrDirectoryOnSystem({ + sessionId: this.sessionId, + fileOrDirectoryPath: filePath, + ifMatch: "", + fileProperties: { name: newName, path }, + }); - this.updateFileMetadata(filePath, response); + this.updateFileMetadata(filePath, response); - return this.filePropertiesToContentItem(response.data); + 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 { From d7e2e1bdf646e09bbcec0eef84820528ca4b7314 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Tue, 19 Nov 2024 15:27:23 -0500 Subject: [PATCH 30/30] chore: account for semicolons in move paths Signed-off-by: Scott Dover --- client/src/connection/rest/RestSASServerAdapter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/connection/rest/RestSASServerAdapter.ts b/client/src/connection/rest/RestSASServerAdapter.ts index f609988bd..d07cfdbb3 100644 --- a/client/src/connection/rest/RestSASServerAdapter.ts +++ b/client/src/connection/rest/RestSASServerAdapter.ts @@ -361,7 +361,10 @@ class RestSASServerAdapter implements ContentAdapter { ifMatch: etag, fileProperties: { name: item.name, - path: newFilePath.split(SAS_FILE_SEPARATOR).join("/"), + path: newFilePath + .split(SAS_FILE_SEPARATOR) + .join("/") + .replace(/~sc~/g, ";"), }, };