Skip to content

Commit 2402cf7

Browse files
authored
feat: add support for file navigation root (#1557)
**Summary** This introduces a number of changes. First, there were a couple of minor changes not specifically related to this issue: - I updated the "delete resource" command to bail early and got rid of one level of navigation - I renamed the various "file" adapters such that they are named consistently w/ the following format: `<Technology><Server|Content>Adapter` (i.e. `RestContentAdapter`, `ItcServerAdapter`) Next, this adds configuration options for `fileNavigationCustomRootPath` and `fileNavigationRoot`. A few things to mention with this one: - using `fileNavigationRoot === 'SYSTEM'` for itc connections has the ability to set the root folder as the system root (i.e. `C:\`). At the moment, `fileNavigationRoot === 'SYSTEM'` has no effect on rest connection types. - If `fileNavigationCustomRootPath` is specified, it'll basically be ignored _until_ `fileNavigationRoot === 'CUSTOM'` - If `fileNavigationCustomRootPath` is specified and `fileNavigationRoot === 'CUSTOM'`, sas server files are loaded with the `Home` directory pointing to `fileNavigationCustomRootPath` - NOTE: If `fileNavigationCustomRootPath` does not exist, an error is displayed to the user pointing them to their settings - Also, a new context menu item, "Copy Path," was introduced to make it easier to see what path is at the root, as well as making it easier to define a new `fileNavigationCustomRootPath`. "Copy Path" is available for sas server & content files with any connection type **Testing** - [x] Test defining various combinations of `fileNavigationCustomRootPath` and `fileNavigationRoot` - [x] Made sure `fileNavigationCustomRootPath` failures provided some detail to end-users - [x] Made sure paths could be copied for files and folders, but not for things in favorites/recycle bin. Made sure paths could also be copied for root folders under rest/itc-based connections - [x] Made sure compute context settings were prioritized over user-defined settings. **Notes on compute-defined file navigation root settings** One can set file navigation root settings using environment manager, using the attribute names mentioned above. Here's an example: <img width="1387" height="970" alt="ev-settings" src="https://github.com/user-attachments/assets/e34b1337-ff71-4173-b832-23a80f33926c" /> If _either_ of the settings are specified by the compute context, they immediately override the user settings.
1 parent 34d8f2d commit 2402cf7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+707
-197
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+
- Add support for `fileNavigationCustomRootPath`/`fileNavigationRoot` for rest & iom/com connections ([#1557](https://github.com/sassoftware/vscode-sas-extension/pull/1557))
12+
713
## [v1.15.0] - 2025-06-10
814

915
### Changed

client/src/components/ContentNavigator/ContentAdapterFactory.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import ITCSASServerAdapter from "../../connection/itc/ITCSASServerAdapter";
4-
import RestSASServerAdapter from "../../connection/rest/RestSASServerAdapter";
5-
import SASContentAdapter from "../../connection/rest/SASContentAdapter";
6-
import { ConnectionType } from "../profile";
3+
import ItcServerAdapter from "../../connection/itc/ItcServerAdapter";
4+
import RestContentAdapter from "../../connection/rest/RestContentAdapter";
5+
import RestServerAdapter from "../../connection/rest/RestServerAdapter";
6+
import { ConnectionType, ProfileWithFileRootOptions } from "../profile";
77
import {
88
ContentAdapter,
99
ContentNavigatorConfig,
@@ -13,18 +13,26 @@ import {
1313
class ContentAdapterFactory {
1414
public create(
1515
connectionType: ConnectionType,
16+
fileNavigationCustomRootPath: ProfileWithFileRootOptions["fileNavigationCustomRootPath"],
17+
fileNavigationRoot: ProfileWithFileRootOptions["fileNavigationRoot"],
1618
sourceType: ContentNavigatorConfig["sourceType"],
1719
): ContentAdapter {
1820
const key = `${connectionType}.${sourceType}`;
1921
switch (key) {
2022
case `${ConnectionType.Rest}.${ContentSourceType.SASServer}`:
21-
return new RestSASServerAdapter();
23+
return new RestServerAdapter(
24+
fileNavigationCustomRootPath,
25+
fileNavigationRoot,
26+
);
2227
case `${ConnectionType.IOM}.${ContentSourceType.SASServer}`:
2328
case `${ConnectionType.COM}.${ContentSourceType.SASServer}`:
24-
return new ITCSASServerAdapter();
29+
return new ItcServerAdapter(
30+
fileNavigationCustomRootPath,
31+
fileNavigationRoot,
32+
);
2533
case `${ConnectionType.Rest}.${ContentSourceType.SASContent}`:
2634
default:
27-
return new SASContentAdapter();
35+
return new RestContentAdapter();
2836
}
2937
}
3038
}

client/src/components/ContentNavigator/ContentDataProvider.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ class ContentDataProvider
225225
arguments: [uri],
226226
title: "Open SAS File",
227227
},
228-
contextValue: item.contextValue,
228+
contextValue: item.contextValue || undefined,
229229
iconPath: this.iconPathForItem(item),
230230
id: item.uid,
231231
label: item.name,
@@ -533,6 +533,10 @@ class ContentDataProvider
533533
}
534534
}
535535

536+
public async getPathOfItem(item: ContentItem) {
537+
return await this.model.getPathOfItem(item);
538+
}
539+
536540
private async childrenSelections(
537541
selection: ContentItem,
538542
allSelections: readonly ContentItem[],

client/src/components/ContentNavigator/ContentModel.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,8 @@ export class ContentModel {
197197
public async restoreResource(item: ContentItem) {
198198
return await this.contentAdapter?.restoreItem(item);
199199
}
200+
201+
public async getPathOfItem(item: ContentItem): Promise<string> {
202+
return await this.contentAdapter.getPathOfItem(item);
203+
}
200204
}

client/src/components/ContentNavigator/const.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ export const Messages = {
7474
FileDragFromFavorites: l10n.t("Unable to drag files from my favorites."),
7575
FileDragFromTrashError: l10n.t("Unable to drag files from trash."),
7676
FileDropError: l10n.t('Unable to drop item "{name}".'),
77+
FileNavigationRootAdminError: l10n.t(
78+
"The files cannot be accessed from the path specified in the context definition for the SAS Compute Server. Contact your SAS administrator.",
79+
),
80+
FileNavigationRootUserError: l10n.t(
81+
"The files cannot be accessed from the specified path.",
82+
),
7783
FileOpenError: l10n.t("The file type is unsupported."),
7884
FileRestoreError: l10n.t("Unable to restore file."),
7985
FileUploadError: l10n.t("Unable to upload files."),

client/src/components/ContentNavigator/index.ts

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import {
99
ProgressLocation,
1010
Uri,
1111
commands,
12+
env,
1213
l10n,
1314
window,
1415
workspace,
1516
} from "vscode";
1617

1718
import { profileConfig } from "../../commands/profile";
1819
import { SubscriptionProvider } from "../SubscriptionProvider";
19-
import { ConnectionType } from "../profile";
20+
import { ConnectionType, ProfileWithFileRootOptions } from "../profile";
2021
import ContentAdapterFactory from "./ContentAdapterFactory";
2122
import ContentDataProvider from "./ContentDataProvider";
2223
import { ContentModel } from "./ContentModel";
@@ -102,33 +103,34 @@ class ContentNavigator implements SubscriptionProvider {
102103
async (item: ContentItem) => {
103104
this.treeViewSelections(item).forEach(
104105
async (resource: ContentItem) => {
106+
if (!resource.contextValue.includes("delete")) {
107+
return;
108+
}
105109
const isContainer = getIsContainer(resource);
106110
const moveToRecycleBin =
107111
this.contentDataProvider.canRecycleResource(resource);
108112

109-
if (resource.contextValue.includes("delete")) {
110-
if (
111-
!moveToRecycleBin &&
112-
!(await window.showWarningMessage(
113-
l10n.t(Messages.DeleteWarningMessage, {
114-
name: resource.name,
115-
}),
116-
{ modal: true },
117-
Messages.DeleteButtonLabel,
118-
))
119-
) {
120-
return;
121-
}
122-
const deleteResult = moveToRecycleBin
123-
? await this.contentDataProvider.recycleResource(resource)
124-
: await this.contentDataProvider.deleteResource(resource);
125-
if (!deleteResult) {
126-
window.showErrorMessage(
127-
isContainer
128-
? Messages.FolderDeletionError
129-
: Messages.FileDeletionError,
130-
);
131-
}
113+
if (
114+
!moveToRecycleBin &&
115+
!(await window.showWarningMessage(
116+
l10n.t(Messages.DeleteWarningMessage, {
117+
name: resource.name,
118+
}),
119+
{ modal: true },
120+
Messages.DeleteButtonLabel,
121+
))
122+
) {
123+
return;
124+
}
125+
const deleteResult = moveToRecycleBin
126+
? await this.contentDataProvider.recycleResource(resource)
127+
: await this.contentDataProvider.deleteResource(resource);
128+
if (!deleteResult) {
129+
window.showErrorMessage(
130+
isContainer
131+
? Messages.FolderDeletionError
132+
: Messages.FileDeletionError,
133+
);
132134
}
133135
},
134136
);
@@ -399,6 +401,15 @@ class ContentNavigator implements SubscriptionProvider {
399401
async (resource: ContentItem) =>
400402
this.uploadResource(resource, { canSelectFiles: false }),
401403
),
404+
commands.registerCommand(
405+
`${SAS}.copyPath`,
406+
async (resource: ContentItem) => {
407+
const path = await this.contentDataProvider.getPathOfItem(resource);
408+
if (path) {
409+
await env.clipboard.writeText(path);
410+
}
411+
},
412+
),
402413
workspace.onDidChangeConfiguration(
403414
async (event: ConfigurationChangeEvent) => {
404415
if (event.affectsConfiguration("SAS.connectionProfiles")) {
@@ -490,10 +501,26 @@ class ContentNavigator implements SubscriptionProvider {
490501
return;
491502
}
492503

504+
const profileWithFileRootOptions = getProfileWithFileRootOptions();
493505
return new ContentAdapterFactory().create(
494506
activeProfile.connectionType,
507+
profileWithFileRootOptions?.fileNavigationCustomRootPath,
508+
profileWithFileRootOptions?.fileNavigationRoot,
495509
this.sourceType,
496510
);
511+
512+
function getProfileWithFileRootOptions():
513+
| ProfileWithFileRootOptions
514+
| undefined {
515+
switch (activeProfile.connectionType) {
516+
case ConnectionType.Rest:
517+
case ConnectionType.IOM:
518+
case ConnectionType.COM:
519+
return activeProfile;
520+
default:
521+
return undefined;
522+
}
523+
}
497524
}
498525
}
499526

client/src/components/ContentNavigator/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,13 @@ export interface ContentAdapter {
8080
getChildItems: (parentItem: ContentItem) => Promise<ContentItem[]>;
8181
getContentOfItem: (item: ContentItem) => Promise<string>;
8282
getContentOfUri: (uri: Uri) => Promise<string>;
83-
getFolderPathForItem: (item: ContentItem) => Promise<string>;
83+
getFolderPathForItem: (item: ContentItem) => Promise<string> | string;
8484
getItemOfUri: (uri: Uri) => Promise<ContentItem>;
8585
getParentOfItem: (item: ContentItem) => Promise<ContentItem | undefined>;
86+
getPathOfItem?: (
87+
item: ContentItem,
88+
folderPathOnly?: boolean,
89+
) => Promise<string>;
8690
getRootFolder: (name: string) => ContentItem | undefined;
8791
getRootItems: () => Promise<RootFolderMap>;
8892
getUriOfItem: (item: ContentItem, readOnly: boolean) => Promise<Uri>;

client/src/components/ContentNavigator/utils.ts

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,17 @@ import {
99
window,
1010
} from "vscode";
1111

12-
import { resourceType } from "../../connection/rest/util";
13-
import { DEFAULT_FILE_CONTENT_TYPE } from "./const";
12+
import { basename } from "path";
13+
14+
import { ProfileWithFileRootOptions } from "../profile";
15+
import {
16+
DEFAULT_FILE_CONTENT_TYPE,
17+
FILE_TYPES,
18+
FOLDER_TYPE,
19+
FOLDER_TYPES,
20+
SERVER_HOME_FOLDER_TYPE,
21+
TRASH_FOLDER_TYPE,
22+
} from "./const";
1423
import mimeTypes from "./mime-types";
1524
import { ContentItem, Permission } from "./types";
1625

@@ -102,7 +111,6 @@ export const convertStaticFolderToContentItem = (
102111
type: FileType.Directory,
103112
},
104113
};
105-
item.contextValue = resourceType(item);
106114
item.typeName = staticFolder.type;
107115
return item;
108116
};
@@ -130,3 +138,106 @@ export const sortedContentItems = (items: ContentItem[]) =>
130138
return a.name.localeCompare(b.name);
131139
}
132140
});
141+
142+
export const homeDirectoryName = (
143+
fileNavigationRoot: ProfileWithFileRootOptions["fileNavigationRoot"],
144+
fileNavigationCustomRootPath: ProfileWithFileRootOptions["fileNavigationCustomRootPath"],
145+
): string => {
146+
const defaultName = "Home";
147+
if (fileNavigationRoot !== "CUSTOM" || !fileNavigationCustomRootPath) {
148+
return defaultName;
149+
}
150+
151+
return basename(fileNavigationCustomRootPath) || defaultName;
152+
};
153+
154+
export const homeDirectoryNameAndType = (
155+
fileNavigationRoot: ProfileWithFileRootOptions["fileNavigationRoot"],
156+
fileNavigationCustomRootPath: ProfileWithFileRootOptions["fileNavigationCustomRootPath"],
157+
): [string, string] => {
158+
const directoryName = homeDirectoryName(
159+
fileNavigationRoot,
160+
fileNavigationCustomRootPath,
161+
);
162+
if (directoryName === "Home") {
163+
return [directoryName, SERVER_HOME_FOLDER_TYPE];
164+
}
165+
166+
return [directoryName, FOLDER_TYPE];
167+
};
168+
169+
export const getTypeName = (item: ContentItem): string =>
170+
item.contentType || item.type;
171+
172+
export const isRootFolder = (item: ContentItem, bStrict?: boolean): boolean => {
173+
const typeName = item.typeName;
174+
if (!bStrict && isItemInRecycleBin(item) && isReference(item)) {
175+
return false;
176+
}
177+
if (FOLDER_TYPES.indexOf(typeName) >= 0) {
178+
return true;
179+
}
180+
return false;
181+
};
182+
183+
export enum ContextMenuAction {
184+
CreateChild = "createChild", // Create a new folder _under_ the current one
185+
Delete = "delete", // The item can be deleted
186+
Update = "update", // The item can be updated/edited/renamed
187+
Restore = "restore", // The item can be restored
188+
CopyPath = "copyPath", // The item path can be copied
189+
Empty = "empty", // Whether or not children can be deleted permanently (for the recycling bin)
190+
AddToFavorites = "addToFavorites", // Item can be added to favorites
191+
RemoveFromFavorites = "removeFromFavorites", // Item can be removed from favorites
192+
ConvertNotebookToFlow = "convertNotebookToFlow", // Allows sasnb files to be converted to flows
193+
AllowDownload = "allowDownload", // Allows downloading files / folders
194+
}
195+
export class ContextMenuProvider {
196+
constructor(
197+
protected readonly validContextMenuActions: ContextMenuAction[],
198+
protected readonly enablementOverrides: Partial<
199+
Record<ContextMenuAction, (item: ContentItem) => boolean>
200+
> = {},
201+
) {}
202+
203+
public availableActions(item: ContentItem): string {
204+
if (!isValidItem(item)) {
205+
return "";
206+
}
207+
208+
const { write, delete: canDelete, addMember } = item.permission;
209+
const isRecycled = isItemInRecycleBin(item);
210+
const type = getTypeName(item);
211+
212+
const menuActionEnablement = {
213+
[ContextMenuAction.CreateChild]: () => addMember && !isRecycled,
214+
[ContextMenuAction.Delete]: () =>
215+
canDelete && !item.flags?.isInMyFavorites,
216+
[ContextMenuAction.Update]: () => write && !isRecycled,
217+
[ContextMenuAction.Restore]: () => write && isRecycled,
218+
[ContextMenuAction.CopyPath]: () => (addMember || write) && !isRecycled,
219+
[ContextMenuAction.Empty]: () =>
220+
type === TRASH_FOLDER_TYPE && !!item?.memberCount,
221+
[ContextMenuAction.AddToFavorites]: () =>
222+
!item.flags?.isInMyFavorites &&
223+
item.type !== "reference" &&
224+
[FOLDER_TYPE, ...FILE_TYPES].includes(type) &&
225+
!isRecycled,
226+
[ContextMenuAction.RemoveFromFavorites]: () =>
227+
item.flags?.isInMyFavorites,
228+
[ContextMenuAction.ConvertNotebookToFlow]: () =>
229+
item?.name?.endsWith(".sasnb"),
230+
[ContextMenuAction.AllowDownload]: () => !isRootFolder(item),
231+
...(this.enablementOverrides || {}),
232+
};
233+
234+
const actions = Object.keys(menuActionEnablement)
235+
.filter((key: ContextMenuAction) =>
236+
this.validContextMenuActions.includes(key),
237+
)
238+
.filter((key) => menuActionEnablement[key](item))
239+
.map((key) => key);
240+
241+
return actions.sort().join("-");
242+
}
243+
}

0 commit comments

Comments
 (0)