From 4c172f6a6928db24becd035438c315ce5833ccb6 Mon Sep 17 00:00:00 2001 From: Scott Dover Date: Tue, 17 Oct 2023 14:00:26 -0400 Subject: [PATCH] feat: allow dragging sas content into editor (#510) **Summary** This adds the ability to drag sas content into sas programs. See https://github.com/sassoftware/vscode-sas-extension/issues/329 for more details **Testing** - [x] Tested dragging a folder into a SAS program (should not provide any code) - [x] Tested dragging a file into a SAS program from my folder - [x] Tested dragging a file into a SAS program from my favorites - [x] Tested dragging files into a SAS program from nested folder paths - [x] Tested dragging two files from the same folder into a SAS program --- .../ContentNavigator/ContentDataProvider.ts | 40 +++++++++++- .../ContentNavigator/ContentModel.ts | 24 +++++++ .../src/components/ContentNavigator/utils.ts | 26 +++++++- .../ContentDataProvider.test.ts | 65 ++++++++++++++++++- .../components/ContentNavigator/utils.test.ts | 27 ++++++++ 5 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 client/test/components/ContentNavigator/utils.test.ts diff --git a/client/src/components/ContentNavigator/ContentDataProvider.ts b/client/src/components/ContentNavigator/ContentDataProvider.ts index de37be914..70b2742db 100644 --- a/client/src/components/ContentNavigator/ContentDataProvider.ts +++ b/client/src/components/ContentNavigator/ContentDataProvider.ts @@ -1,19 +1,23 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { + CancellationToken, DataTransfer, DataTransferItem, Disposable, + DocumentDropEdit, Event, EventEmitter, FileChangeEvent, FileStat, FileSystemProvider, FileType, + Position, ProviderResult, Tab, TabInputNotebook, TabInputText, + TextDocument, TextDocumentContentProvider, ThemeIcon, TreeDataProvider, @@ -24,6 +28,7 @@ import { Uri, commands, l10n, + languages, window, } from "vscode"; @@ -45,6 +50,7 @@ import { convertNotebookToFlow } from "./convert"; import { ContentItem } from "./types"; import { getCreationDate, + getFileStatement, getId, isContainer as getIsContainer, getLabel, @@ -71,6 +77,7 @@ class ContentDataProvider private _onDidChangeTreeData: EventEmitter; private _onDidChange: EventEmitter; private _treeView: TreeView; + private _dropEditProvider: Disposable; private readonly model: ContentModel; private extensionUri: Uri; @@ -93,6 +100,10 @@ class ContentDataProvider dragAndDropController: this, canSelectMany: true, }); + this._dropEditProvider = languages.registerDocumentDropEditProvider( + { language: "sas" }, + this, + ); this._treeView.onDidChangeVisibility(async () => { if (this._treeView.visible) { @@ -140,8 +151,35 @@ class ContentDataProvider dataTransfer.set(this.dragMimeTypes[0], dataTransferItem); } + public async provideDocumentDropEdits( + document: TextDocument, + position: Position, + dataTransfer: DataTransfer, + token: CancellationToken, + ): Promise { + const dataTransferItem = dataTransfer.get(this.dragMimeTypes[0]); + const contentItem = + dataTransferItem && JSON.parse(dataTransferItem.value)[0]; + if (token.isCancellationRequested || !contentItem) { + return undefined; + } + + const fileFolderPath = await this.model.getFileFolderPath(contentItem); + if (!fileFolderPath) { + return undefined; + } + + return { + insertText: getFileStatement( + contentItem.name, + document.getText(), + fileFolderPath, + ), + }; + } + public getSubscriptions(): Disposable[] { - return [this._treeView]; + return [this._treeView, this._dropEditProvider]; } get onDidChangeFile(): Event { diff --git a/client/src/components/ContentNavigator/ContentModel.ts b/client/src/components/ContentNavigator/ContentModel.ts index 2c63ecbd8..60987bbfd 100644 --- a/client/src/components/ContentNavigator/ContentModel.ts +++ b/client/src/components/ContentNavigator/ContentModel.ts @@ -640,6 +640,30 @@ export class ContentModel { } return "unknown"; } + + public async getFileFolderPath(contentItem: ContentItem): Promise { + if (isContainer(contentItem)) { + return ""; + } + + const filePathParts = []; + let currentContentItem: Pick = + contentItem; + do { + try { + const { data: parentData } = await this.connection.get( + currentContentItem.parentFolderUri, + ); + currentContentItem = parentData; + } catch (e) { + return ""; + } + + filePathParts.push(currentContentItem.name); + } while (currentContentItem.parentFolderUri); + + return "/" + filePathParts.reverse().join("/"); + } } const getPermission = (item: ContentItem): Permission => { diff --git a/client/src/components/ContentNavigator/utils.ts b/client/src/components/ContentNavigator/utils.ts index f7c1ba6e2..13ad5e835 100644 --- a/client/src/components/ContentNavigator/utils.ts +++ b/client/src/components/ContentNavigator/utils.ts @@ -1,6 +1,6 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Uri } from "vscode"; +import { SnippetString, Uri } from "vscode"; import { FILE_TYPE, @@ -111,3 +111,27 @@ export const isItemInRecycleBin = (item: ContentItem): boolean => !!item && item.flags?.isInRecycleBin; export const isContentItem = (item): item is ContentItem => isValidItem(item); + +// A document uses uppercase letters _if_ are no words +// (where word means gte 3 characters) that are lowercase. +const documentUsesUppercase = (documentContent: string) => + documentContent && + !documentContent + // Exclude anything in quotes from our calculations + .replace(/('|")([^('|")]*)('|")/g, "") + .match(/([a-z]{3,})\S/g); + +export const getFileStatement = ( + contentItemName: string, + documentContent: string, + fileFolderPath: string, +): SnippetString => { + const usesUppercase = documentUsesUppercase(documentContent); + const cmd = "filename ${1:fileref} filesrvc folderpath='$1' filename='$2';\n"; + + return new SnippetString( + (usesUppercase ? cmd.toUpperCase() : cmd) + .replace("$1", fileFolderPath.replace(/'/g, "''")) + .replace("$2", contentItemName.replace(/'/g, "''")), + ); +}; diff --git a/client/test/components/ContentNavigator/ContentDataProvider.test.ts b/client/test/components/ContentNavigator/ContentDataProvider.test.ts index 3608eef37..9b6214585 100644 --- a/client/test/components/ContentNavigator/ContentDataProvider.test.ts +++ b/client/test/components/ContentNavigator/ContentDataProvider.test.ts @@ -722,8 +722,6 @@ describe("ContentDataProvider", async function () { const dataTransferItem = new DataTransferItem(uri); dataTransfer.set("text/uri-list", dataTransferItem); - console.log("this bithc"); - stub.returns(new Promise((resolve) => resolve(item))); await dataProvider.handleDrop(parentItem, dataTransfer); @@ -889,4 +887,67 @@ describe("ContentDataProvider", async function () { expect(stub.calledWith(item, getLink(parentItem.links, "GET", "self")?.uri)) .to.be.true; }); + + it("getFileFolderPath - returns empty path for folder", async function () { + const item = mockContentItem({ + type: "folder", + name: "folder", + }); + + const model = new ContentModel(); + const dataProvider = new ContentDataProvider( + model, + Uri.from({ scheme: "http" }), + ); + + await dataProvider.connect("http://test.io"); + const path = await model.getFileFolderPath(item); + + expect(path).to.equal(""); + }); + + it("getFileFolderPath - traverses parentFolderUri to find path", async function () { + const grandparent = mockContentItem({ + type: "folder", + name: "grandparent", + id: "/id/grandparent", + }); + const parent = mockContentItem({ + type: "folder", + name: "parent", + id: "/id/parent", + parentFolderUri: "/id/grandparent", + }); + const item = mockContentItem({ + type: "file", + name: "file.sas", + parentFolderUri: "/id/parent", + }); + const item2 = mockContentItem({ + type: "file", + name: "file2.sas", + parentFolderUri: "/id/parent", + }); + + const model = new ContentModel(); + const dataProvider = new ContentDataProvider( + model, + Uri.from({ scheme: "http" }), + ); + + axiosInstance.get.withArgs("/id/parent").resolves({ + data: parent, + }); + axiosInstance.get.withArgs("/id/grandparent").resolves({ + data: grandparent, + }); + + await dataProvider.connect("http://test.io"); + + // We expect both files to have the same folder path + expect(await model.getFileFolderPath(item)).to.equal("/grandparent/parent"); + expect(await model.getFileFolderPath(item2)).to.equal( + "/grandparent/parent", + ); + }); }); diff --git a/client/test/components/ContentNavigator/utils.test.ts b/client/test/components/ContentNavigator/utils.test.ts new file mode 100644 index 000000000..5cf907483 --- /dev/null +++ b/client/test/components/ContentNavigator/utils.test.ts @@ -0,0 +1,27 @@ +import { expect } from "chai"; + +import { getFileStatement } from "../../../src/components/ContentNavigator/utils"; + +describe("utils", async function () { + it("getFileStatement - returns extensionless name + numeric suffix with no content", () => { + expect(getFileStatement("testcsv.csv", "", "/path").value).to.equal( + `filename \${1:fileref} filesrvc folderpath='/path' filename='testcsv.csv';\n`, + ); + }); + + it("getFileStatement - returns uppercase name + suffix with uppercase content", () => { + expect( + getFileStatement("testcsv.csv", "UPPER CASE CONTENT", "/path").value, + ).to.equal( + `FILENAME \${1:FILEREF} FILESRVC FOLDERPATH='/path' FILENAME='testcsv.csv';\n`, + ); + }); + + it("getFileStatement - returns encoded filename when filename contains quotes", () => { + expect( + getFileStatement("testcsv-'withquotes'.csv", "", "/path").value, + ).to.equal( + `filename \${1:fileref} filesrvc folderpath='/path' filename='testcsv-''withquotes''.csv';\n`, + ); + }); +});