Skip to content

Commit 74d93ad

Browse files
authored
feat: implement sas file system for viya connections (#1203)
**Summary** This adds sas file system support for viya connections. Notable changes include: - Introducing a `canRecycleResource` function to sas content adapter. This allows us to make a determination about whether or not we should show a dialog for deleted files. Since SAS file system doesn't support recycle bin, we show the deletion message every time a file is deleted (since it's a permanent deletion) - Creates a distinct connection for `NotebookConverter` for connecting to sas studio (instead of re-using content model's connection) - Favorites are not implemented in this pull request and will be implemented in a future PR **Testing** - [x] File/folder creation - [x] Create file/folder w/ context menu - [x] Create file/folder by upload - [x] Create file/folder by drag & drop (create multiple files) - [x] File/folder deletion - [x] Test file deletion with context menu - [x] Test multi-file deletion with context menu - [x] File/folder updates - [x] Test updating file/folder name - [x] Test updating file contents - [x] Test moving file/folder (multiple files/folders) - [x] Test downloading files/folders - [x] Make sure refresh works as expected - [x] Make sure connections are automatically refreshed after they become stale - [x] Make sure a sas notebook file can be converted to a flow (test with sas content as well) - [x] Make sure we're displaying all files/folders for sas server and that items are sorted by type (directories before files), then alphabetically - [x] Make sure we can collapse all folders **TODOs** - [x] Update CHANGELOG - [x] Update `matrix.md` with details about sas server
1 parent 076a436 commit 74d93ad

40 files changed

+1181
-163
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
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.
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- Added support for SAS server for viya connections ([#1203](https://github.com/sassoftware/vscode-sas-extension/pull/1203))
12+
713
## [v1.11.0] - 2024-10-09
814

915
### Added

client/src/components/ContentNavigator/ContentAdapterFactory.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3+
import RestSASServerAdapter from "../../connection/rest/RestSASServerAdapter";
34
import SASContentAdapter from "../../connection/rest/SASContentAdapter";
45
import { ConnectionType } from "../profile";
56
import {
@@ -9,13 +10,15 @@ import {
910
} from "./types";
1011

1112
class ContentAdapterFactory {
12-
// TODO #889 Update this to return RestSASServerAdapter & ITCSASServerAdapter
13+
// TODO #889 Update this to return ITCSASServerAdapter
1314
public create(
1415
connectionType: ConnectionType,
1516
sourceType: ContentNavigatorConfig["sourceType"],
1617
): ContentAdapter {
1718
const key = `${connectionType}.${sourceType}`;
1819
switch (key) {
20+
case `${ConnectionType.Rest}.${ContentSourceType.SASServer}`:
21+
return new RestSASServerAdapter();
1922
case `${ConnectionType.Rest}.${ContentSourceType.SASContent}`:
2023
default:
2124
return new SASContentAdapter();

client/src/components/ContentNavigator/ContentDataProvider.ts

Lines changed: 120 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
FileType,
1515
Position,
1616
ProviderResult,
17-
Tab,
1817
TabInputNotebook,
1918
TabInputText,
2019
TextDocument,
@@ -45,14 +44,20 @@ import {
4544
FAVORITES_FOLDER_TYPE,
4645
Messages,
4746
ROOT_FOLDER_TYPE,
47+
SERVER_HOME_FOLDER_TYPE,
48+
SERVER_ROOT_FOLDER_TYPE,
4849
TRASH_FOLDER_TYPE,
4950
} from "./const";
5051
import {
5152
ContentItem,
5253
ContentNavigatorConfig,
5354
FileManipulationEvent,
5455
} from "./types";
55-
import { getFileStatement, isContainer as getIsContainer } from "./utils";
56+
import {
57+
getEditorTabsForItem,
58+
getFileStatement,
59+
isContainer as getIsContainer,
60+
} from "./utils";
5661

5762
class ContentDataProvider
5863
implements
@@ -68,7 +73,7 @@ class ContentDataProvider
6873
private _onDidChange: EventEmitter<Uri>;
6974
private _treeView: TreeView<ContentItem>;
7075
private _dropEditProvider: Disposable;
71-
private readonly model: ContentModel;
76+
private model: ContentModel;
7277
private extensionUri: Uri;
7378
private mimeType: string;
7479

@@ -114,6 +119,10 @@ class ContentDataProvider
114119
});
115120
}
116121

122+
public useModel(contentModel: ContentModel) {
123+
this.model = contentModel;
124+
}
125+
117126
public async handleDrop(
118127
target: ContentItem,
119128
sources: DataTransfer,
@@ -278,23 +287,68 @@ class ContentDataProvider
278287
name: string,
279288
): Promise<Uri | undefined> {
280289
const closing = closeFileIfOpen(item);
281-
if (!(await closing)) {
290+
const removedTabUris = await closing;
291+
if (!removedTabUris) {
282292
return;
283293
}
284294

285295
const newItem = await this.model.renameResource(item, name);
286-
if (newItem) {
287-
const newUri = newItem.vscUri;
288-
if (closing !== true) {
289-
// File was open before rename, so re-open it
290-
commands.executeCommand("vscode.open", newUri);
296+
if (!newItem) {
297+
return;
298+
}
299+
300+
const newUri = newItem.vscUri;
301+
const oldUriToNewUriMap = [[item.vscUri, newUri]];
302+
const newItemIsContainer = getIsContainer(newItem);
303+
if (closing !== true && !newItemIsContainer) {
304+
await commands.executeCommand("vscode.openWith", newUri, "default", {
305+
preview: false,
306+
});
307+
}
308+
if (closing !== true && newItemIsContainer) {
309+
const urisToOpen = getPreviouslyOpenedChildItems(
310+
await this.getChildren(newItem),
311+
);
312+
for (const [, newUri] of urisToOpen) {
313+
await commands.executeCommand("vscode.openWith", newUri, "default", {
314+
preview: false,
315+
});
291316
}
317+
oldUriToNewUriMap.push(...urisToOpen);
318+
}
319+
oldUriToNewUriMap.forEach(([uri, newUri]) =>
292320
this._onDidManipulateFile.fire({
293321
type: "rename",
294-
uri: item.vscUri,
322+
uri,
295323
newUri,
296-
});
297-
return newUri;
324+
}),
325+
);
326+
return newUri;
327+
328+
function getPreviouslyOpenedChildItems(childItems: ContentItem[]) {
329+
const loadChildItems = closing !== true && newItemIsContainer;
330+
if (!Array.isArray(removedTabUris) || !loadChildItems) {
331+
return [];
332+
}
333+
// Here's where things get a little weird. When we rename folders in
334+
// sas content, we _don't_ close those files. It doesn't matter since
335+
// their path isn't hierarchical. In sas file system, the path is hierarchical,
336+
// thus we need to re-open all the closed files. This does that by getting
337+
// children and comparing the removedTabUris
338+
const filteredChildItems = childItems
339+
.map((childItem) => {
340+
const matchingUri = removedTabUris.find((uri) =>
341+
uri.path.endsWith(childItem.name),
342+
);
343+
if (!matchingUri) {
344+
return;
345+
}
346+
347+
return [matchingUri, childItem.vscUri];
348+
})
349+
.filter((exists) => exists);
350+
351+
return filteredChildItems;
298352
}
299353
}
300354

@@ -314,6 +368,10 @@ class ContentDataProvider
314368
return success;
315369
}
316370

371+
public canRecycleResource(item: ContentItem): boolean {
372+
return this.model.canRecycleResource(item);
373+
}
374+
317375
public async recycleResource(item: ContentItem): Promise<boolean> {
318376
if (!(await closeFileIfOpen(item))) {
319377
return false;
@@ -495,13 +553,34 @@ class ContentDataProvider
495553
return this.getChildren(selection);
496554
}
497555

556+
private async moveItem(
557+
item: ContentItem,
558+
targetUri: string,
559+
): Promise<boolean> {
560+
if (!targetUri) {
561+
return false;
562+
}
563+
564+
const closing = closeFileIfOpen(item);
565+
if (!(await closing)) {
566+
return false;
567+
}
568+
569+
const newUri = await this.model.moveTo(item, targetUri);
570+
if (closing !== true) {
571+
commands.executeCommand("vscode.open", newUri);
572+
}
573+
574+
return !!newUri;
575+
}
576+
498577
private async handleContentItemDrop(
499578
target: ContentItem,
500579
item: ContentItem,
501580
): Promise<void> {
502581
let success = false;
503582
let message = Messages.FileDropError;
504-
if (item.flags.isInRecycleBin) {
583+
if (item.flags?.isInRecycleBin) {
505584
message = Messages.FileDragFromTrashError;
506585
} else if (item.isReference) {
507586
message = Messages.FileDragFromFavorites;
@@ -511,10 +590,7 @@ class ContentDataProvider
511590
success = await this.addToMyFavorites(item);
512591
} else {
513592
const targetUri = target.resourceId;
514-
if (targetUri) {
515-
success = await this.model.moveTo(item, targetUri);
516-
}
517-
593+
success = await this.moveItem(item, targetUri);
518594
if (success) {
519595
this.refresh();
520596
}
@@ -637,6 +713,12 @@ class ContentDataProvider
637713
case FAVORITES_FOLDER_TYPE:
638714
icon = "favoritesFolder";
639715
break;
716+
case SERVER_HOME_FOLDER_TYPE:
717+
icon = "userWorkspace";
718+
break;
719+
case SERVER_ROOT_FOLDER_TYPE:
720+
icon = "server";
721+
break;
640722
default:
641723
icon = "folder";
642724
break;
@@ -647,6 +729,7 @@ class ContentDataProvider
647729
icon = "sasProgramFile";
648730
}
649731
}
732+
650733
return icon !== ""
651734
? {
652735
dark: Uri.joinPath(this.extensionUri, `icons/dark/${icon}Dark.svg`),
@@ -661,17 +744,26 @@ class ContentDataProvider
661744

662745
export default ContentDataProvider;
663746

664-
const closeFileIfOpen = (item: ContentItem) => {
665-
const fileUri = item.vscUri;
666-
const tabs: Tab[] = window.tabGroups.all.map((tg) => tg.tabs).flat();
667-
const tab = tabs.find(
668-
(tab) =>
669-
(tab.input instanceof TabInputText ||
670-
tab.input instanceof TabInputNotebook) &&
671-
tab.input.uri.query === fileUri.query, // compare the file id
672-
);
673-
if (tab) {
674-
return window.tabGroups.close(tab);
747+
const closeFileIfOpen = (item: ContentItem): Promise<Uri[]> | boolean => {
748+
const tabs = getEditorTabsForItem(item);
749+
if (tabs.length > 0) {
750+
return new Promise((resolve, reject) => {
751+
Promise.all(tabs.map((tab) => window.tabGroups.close(tab)))
752+
.then(() =>
753+
resolve(
754+
tabs
755+
.map(
756+
(tab) =>
757+
(tab.input instanceof TabInputText ||
758+
tab.input instanceof TabInputNotebook) &&
759+
tab.input.uri,
760+
)
761+
.filter((exists) => exists),
762+
),
763+
)
764+
.catch(reject);
765+
});
675766
}
767+
676768
return true;
677769
};

client/src/components/ContentNavigator/ContentModel.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import { Uri } from "vscode";
3+
import { Uri, l10n } from "vscode";
44

5-
import { Messages, ROOT_FOLDERS } from "./const";
5+
import { extname } from "path";
6+
7+
import { ALL_ROOT_FOLDERS, Messages } from "./const";
68
import { ContentAdapter, ContentItem } from "./types";
9+
import { isItemInRecycleBin } from "./utils";
710

811
export class ContentModel {
912
private contentAdapter: ContentAdapter;
@@ -33,7 +36,8 @@ export class ContentModel {
3336
return Object.entries(await this.contentAdapter.getRootItems())
3437
.sort(
3538
// sort the delegate folders as the order in the supportedDelegateFolders
36-
(a, b) => ROOT_FOLDERS.indexOf(a[0]) - ROOT_FOLDERS.indexOf(b[0]),
39+
(a, b) =>
40+
ALL_ROOT_FOLDERS.indexOf(a[0]) - ALL_ROOT_FOLDERS.indexOf(b[0]),
3741
)
3842
.map((entry) => entry[1]);
3943
}
@@ -90,6 +94,44 @@ export class ContentModel {
9094
);
9195
}
9296

97+
public async createUniqueFileOfPrefix(
98+
parentItem: ContentItem,
99+
fileName: string,
100+
buffer?: ArrayBufferLike,
101+
) {
102+
const itemsInFolder = await this.getChildren(parentItem);
103+
const uniqueFileName = getUniqueFileName();
104+
105+
return await this.createFile(parentItem, uniqueFileName, buffer);
106+
107+
function getUniqueFileName(): string {
108+
const ext = extname(fileName);
109+
const basename = fileName.replace(ext, "");
110+
const usedFlowNames = itemsInFolder.reduce((carry, item) => {
111+
if (item.name.endsWith(ext)) {
112+
return { ...carry, [item.name]: true };
113+
}
114+
return carry;
115+
}, {});
116+
117+
if (!usedFlowNames[fileName]) {
118+
return fileName;
119+
}
120+
121+
let number = 1;
122+
let newFileName;
123+
do {
124+
newFileName = l10n.t("{basename}_Copy{number}{ext}", {
125+
basename,
126+
number: number++,
127+
ext,
128+
});
129+
} while (usedFlowNames[newFileName]);
130+
131+
return newFileName || fileName;
132+
}
133+
}
134+
93135
public async createFolder(
94136
item: ContentItem,
95137
name: string,
@@ -127,7 +169,7 @@ export class ContentModel {
127169
public async moveTo(
128170
item: ContentItem,
129171
targetParentFolderUri: string,
130-
): Promise<boolean> {
172+
): Promise<boolean | Uri> {
131173
return await this.contentAdapter.moveItem(item, targetParentFolderUri);
132174
}
133175

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

184+
public canRecycleResource(item: ContentItem): boolean {
185+
return (
186+
this.contentAdapter.recycleItem &&
187+
this.contentAdapter.restoreItem &&
188+
!isItemInRecycleBin(item) &&
189+
item.permission.write
190+
);
191+
}
192+
142193
public async recycleResource(item: ContentItem) {
143-
return await this.contentAdapter.recycleItem(item);
194+
return await this.contentAdapter?.recycleItem(item);
144195
}
145196

146197
public async restoreResource(item: ContentItem) {
147-
return await this.contentAdapter.restoreItem(item);
198+
return await this.contentAdapter?.restoreItem(item);
148199
}
149200
}

0 commit comments

Comments
 (0)