Skip to content

Commit

Permalink
feat: implement sas file system for viya connections (#1203)
Browse files Browse the repository at this point in the history
**Summary**
This adds sas file system support for viya connections. Notable changes include:
 - Introducing a `canRecycleResource` function to sas content adapter. This allows us to make a determination about whether or not we should show a dialog for deleted files. Since SAS file system doesn't support recycle bin, we show the deletion message every time a file is deleted (since it's a permanent deletion)
 - Creates a distinct connection for `NotebookConverter` for connecting to sas studio (instead of re-using content model's connection)
 - Favorites are not implemented in this pull request and will be implemented in a future PR

**Testing**
 - [x] File/folder creation
    - [x] Create file/folder w/ context menu
    - [x] Create file/folder by upload
    - [x] Create file/folder by drag & drop (create multiple files)
 - [x] File/folder deletion
    - [x] Test file deletion with context menu
    - [x] Test multi-file deletion with context menu
 - [x] File/folder updates
    - [x] Test updating file/folder name
    - [x] Test updating file contents
    - [x] Test moving file/folder (multiple files/folders)
 - [x] Test downloading files/folders
 - [x] Make sure refresh works as expected
    - [x] Make sure connections are automatically refreshed after they become stale
 - [x] Make sure a sas notebook file can be converted to a flow (test with sas content as well)
 - [x] Make sure we're displaying all files/folders for sas server and that items are sorted by type (directories before files), then alphabetically
 - [x] Make sure we can collapse all folders

**TODOs**
 - [x] Update CHANGELOG
 - [x] Update `matrix.md` with details about sas server
  • Loading branch information
scottdover authored Nov 21, 2024
1 parent 076a436 commit 74d93ad
Show file tree
Hide file tree
Showing 40 changed files with 1,181 additions and 163 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,13 +10,15 @@ import {
} from "./types";

class ContentAdapterFactory {
// TODO #889 Update this to return RestSASServerAdapter & ITCSASServerAdapter
// TODO #889 Update this to return ITCSASServerAdapter
public create(
connectionType: ConnectionType,
sourceType: ContentNavigatorConfig["sourceType"],
): ContentAdapter {
const key = `${connectionType}.${sourceType}`;
switch (key) {
case `${ConnectionType.Rest}.${ContentSourceType.SASServer}`:
return new RestSASServerAdapter();
case `${ConnectionType.Rest}.${ContentSourceType.SASContent}`:
default:
return new SASContentAdapter();
Expand Down
148 changes: 120 additions & 28 deletions client/src/components/ContentNavigator/ContentDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
FileType,
Position,
ProviderResult,
Tab,
TabInputNotebook,
TabInputText,
TextDocument,
Expand Down Expand Up @@ -45,14 +44,20 @@ import {
FAVORITES_FOLDER_TYPE,
Messages,
ROOT_FOLDER_TYPE,
SERVER_HOME_FOLDER_TYPE,
SERVER_ROOT_FOLDER_TYPE,
TRASH_FOLDER_TYPE,
} from "./const";
import {
ContentItem,
ContentNavigatorConfig,
FileManipulationEvent,
} from "./types";
import { getFileStatement, isContainer as getIsContainer } from "./utils";
import {
getEditorTabsForItem,
getFileStatement,
isContainer as getIsContainer,
} from "./utils";

class ContentDataProvider
implements
Expand All @@ -68,7 +73,7 @@ class ContentDataProvider
private _onDidChange: EventEmitter<Uri>;
private _treeView: TreeView<ContentItem>;
private _dropEditProvider: Disposable;
private readonly model: ContentModel;
private model: ContentModel;
private extensionUri: Uri;
private mimeType: string;

Expand Down Expand Up @@ -114,6 +119,10 @@ class ContentDataProvider
});
}

public useModel(contentModel: ContentModel) {
this.model = contentModel;
}

public async handleDrop(
target: ContentItem,
sources: DataTransfer,
Expand Down Expand Up @@ -278,23 +287,68 @@ class ContentDataProvider
name: string,
): Promise<Uri | undefined> {
const closing = closeFileIfOpen(item);
if (!(await closing)) {
const removedTabUris = await closing;
if (!removedTabUris) {
return;
}

const newItem = await this.model.renameResource(item, name);
if (newItem) {
const newUri = newItem.vscUri;
if (closing !== true) {
// File was open before rename, so re-open it
commands.executeCommand("vscode.open", newUri);
if (!newItem) {
return;
}

const newUri = newItem.vscUri;
const oldUriToNewUriMap = [[item.vscUri, newUri]];
const newItemIsContainer = getIsContainer(newItem);
if (closing !== true && !newItemIsContainer) {
await commands.executeCommand("vscode.openWith", newUri, "default", {
preview: false,
});
}
if (closing !== true && newItemIsContainer) {
const urisToOpen = getPreviouslyOpenedChildItems(
await this.getChildren(newItem),
);
for (const [, newUri] of urisToOpen) {
await commands.executeCommand("vscode.openWith", newUri, "default", {
preview: false,
});
}
oldUriToNewUriMap.push(...urisToOpen);
}
oldUriToNewUriMap.forEach(([uri, newUri]) =>
this._onDidManipulateFile.fire({
type: "rename",
uri: item.vscUri,
uri,
newUri,
});
return newUri;
}),
);
return newUri;

function getPreviouslyOpenedChildItems(childItems: ContentItem[]) {
const loadChildItems = closing !== true && newItemIsContainer;
if (!Array.isArray(removedTabUris) || !loadChildItems) {
return [];
}
// Here's where things get a little weird. When we rename folders in
// sas content, we _don't_ close those files. It doesn't matter since
// their path isn't hierarchical. In sas file system, the path is hierarchical,
// thus we need to re-open all the closed files. This does that by getting
// children and comparing the removedTabUris
const filteredChildItems = childItems
.map((childItem) => {
const matchingUri = removedTabUris.find((uri) =>
uri.path.endsWith(childItem.name),
);
if (!matchingUri) {
return;
}

return [matchingUri, childItem.vscUri];
})
.filter((exists) => exists);

return filteredChildItems;
}
}

Expand All @@ -314,6 +368,10 @@ class ContentDataProvider
return success;
}

public canRecycleResource(item: ContentItem): boolean {
return this.model.canRecycleResource(item);
}

public async recycleResource(item: ContentItem): Promise<boolean> {
if (!(await closeFileIfOpen(item))) {
return false;
Expand Down Expand Up @@ -495,13 +553,34 @@ class ContentDataProvider
return this.getChildren(selection);
}

private async moveItem(
item: ContentItem,
targetUri: string,
): Promise<boolean> {
if (!targetUri) {
return false;
}

const closing = closeFileIfOpen(item);
if (!(await closing)) {
return false;
}

const newUri = await this.model.moveTo(item, targetUri);
if (closing !== true) {
commands.executeCommand("vscode.open", newUri);
}

return !!newUri;
}

private async handleContentItemDrop(
target: ContentItem,
item: ContentItem,
): Promise<void> {
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;
Expand All @@ -511,10 +590,7 @@ class ContentDataProvider
success = await this.addToMyFavorites(item);
} else {
const targetUri = target.resourceId;
if (targetUri) {
success = await this.model.moveTo(item, targetUri);
}

success = await this.moveItem(item, targetUri);
if (success) {
this.refresh();
}
Expand Down Expand Up @@ -637,6 +713,12 @@ class ContentDataProvider
case FAVORITES_FOLDER_TYPE:
icon = "favoritesFolder";
break;
case SERVER_HOME_FOLDER_TYPE:
icon = "userWorkspace";
break;
case SERVER_ROOT_FOLDER_TYPE:
icon = "server";
break;
default:
icon = "folder";
break;
Expand All @@ -647,6 +729,7 @@ class ContentDataProvider
icon = "sasProgramFile";
}
}

return icon !== ""
? {
dark: Uri.joinPath(this.extensionUri, `icons/dark/${icon}Dark.svg`),
Expand All @@ -661,17 +744,26 @@ class ContentDataProvider

export default ContentDataProvider;

const closeFileIfOpen = (item: ContentItem) => {
const fileUri = item.vscUri;
const tabs: Tab[] = window.tabGroups.all.map((tg) => tg.tabs).flat();
const tab = tabs.find(
(tab) =>
(tab.input instanceof TabInputText ||
tab.input instanceof TabInputNotebook) &&
tab.input.uri.query === fileUri.query, // compare the file id
);
if (tab) {
return window.tabGroups.close(tab);
const closeFileIfOpen = (item: ContentItem): Promise<Uri[]> | 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;
};
63 changes: 57 additions & 6 deletions client/src/components/ContentNavigator/ContentModel.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { Uri } from "vscode";
import { Uri, l10n } from "vscode";

import { Messages, ROOT_FOLDERS } from "./const";
import { extname } from "path";

import { ALL_ROOT_FOLDERS, Messages } from "./const";
import { ContentAdapter, ContentItem } from "./types";
import { isItemInRecycleBin } from "./utils";

export class ContentModel {
private contentAdapter: ContentAdapter;
Expand Down Expand Up @@ -33,7 +36,8 @@ export class ContentModel {
return Object.entries(await this.contentAdapter.getRootItems())
.sort(
// sort the delegate folders as the order in the supportedDelegateFolders
(a, b) => ROOT_FOLDERS.indexOf(a[0]) - ROOT_FOLDERS.indexOf(b[0]),
(a, b) =>
ALL_ROOT_FOLDERS.indexOf(a[0]) - ALL_ROOT_FOLDERS.indexOf(b[0]),
)
.map((entry) => entry[1]);
}
Expand Down Expand Up @@ -90,6 +94,44 @@ export class ContentModel {
);
}

public async createUniqueFileOfPrefix(
parentItem: ContentItem,
fileName: string,
buffer?: ArrayBufferLike,
) {
const itemsInFolder = await this.getChildren(parentItem);
const uniqueFileName = getUniqueFileName();

return await this.createFile(parentItem, uniqueFileName, buffer);

function getUniqueFileName(): string {
const ext = extname(fileName);
const basename = fileName.replace(ext, "");
const usedFlowNames = itemsInFolder.reduce((carry, item) => {
if (item.name.endsWith(ext)) {
return { ...carry, [item.name]: true };
}
return carry;
}, {});

if (!usedFlowNames[fileName]) {
return fileName;
}

let number = 1;
let newFileName;
do {
newFileName = l10n.t("{basename}_Copy{number}{ext}", {
basename,
number: number++,
ext,
});
} while (usedFlowNames[newFileName]);

return newFileName || fileName;
}
}

public async createFolder(
item: ContentItem,
name: string,
Expand Down Expand Up @@ -127,7 +169,7 @@ export class ContentModel {
public async moveTo(
item: ContentItem,
targetParentFolderUri: string,
): Promise<boolean> {
): Promise<boolean | Uri> {
return await this.contentAdapter.moveItem(item, targetParentFolderUri);
}

Expand All @@ -139,11 +181,20 @@ export class ContentModel {
return await this.contentAdapter.getFolderPathForItem(contentItem);
}

public canRecycleResource(item: ContentItem): boolean {
return (
this.contentAdapter.recycleItem &&
this.contentAdapter.restoreItem &&
!isItemInRecycleBin(item) &&
item.permission.write
);
}

public async recycleResource(item: ContentItem) {
return await this.contentAdapter.recycleItem(item);
return await this.contentAdapter?.recycleItem(item);
}

public async restoreResource(item: ContentItem) {
return await this.contentAdapter.restoreItem(item);
return await this.contentAdapter?.restoreItem(item);
}
}
Loading

0 comments on commit 74d93ad

Please sign in to comment.