From 85f606c309d6a7bc879cbcf8f688bf2cdcbe2edf Mon Sep 17 00:00:00 2001 From: Brett Saviano Date: Thu, 20 Feb 2025 07:06:42 -0500 Subject: [PATCH 1/4] Server-side improvements --- package.json | 8 +- src/api/index.ts | 11 +- src/commands/addServerNamespaceToWorkspace.ts | 61 ++-- src/commands/project.ts | 178 +++++----- src/commands/serverActions.ts | 13 +- src/commands/studio.ts | 13 +- src/commands/unitTest.ts | 40 ++- src/debug/debugSession.ts | 7 +- src/providers/DocumentContentProvider.ts | 58 ++- src/providers/FileSystemProvider/Directory.ts | 21 -- src/providers/FileSystemProvider/File.ts | 21 -- .../FileSystemProvider/FileSearchProvider.ts | 108 +++--- .../FileSystemProvider/FileSystemProvider.ts | 258 +++++++------- .../FileSystemProvider/TextSearchProvider.ts | 335 ++++++++++-------- src/providers/WorkspaceSymbolProvider.ts | 24 +- src/utils/FileProviderUtil.ts | 91 +++-- src/utils/index.ts | 68 +--- 17 files changed, 632 insertions(+), 683 deletions(-) delete mode 100644 src/providers/FileSystemProvider/Directory.ts delete mode 100644 src/providers/FileSystemProvider/File.ts diff --git a/package.json b/package.json index 928b32ec..12db33ce 100644 --- a/package.json +++ b/package.json @@ -581,22 +581,22 @@ }, { "command": "vscode-objectscript.addItemsToProject", - "when": "vscode-objectscript.connectActive && resourceScheme =~ /^isfs(-readonly)?$/ && resource =~ /project%3D/ && explorerResourceIsRoot && !listMultiSelection", + "when": "vscode-objectscript.connectActive && resourceScheme =~ /^isfs(-readonly)?$/ && resource =~ /project%3D/ && resourcePath =~ /^\\/?$/ && !listMultiSelection", "group": "objectscript_prj@1" }, { "command": "vscode-objectscript.removeFromProject", - "when": "vscode-objectscript.connectActive && resourceScheme =~ /^isfs(-readonly)?$/ && resource =~ /project%3D/ && !explorerResourceIsRoot && !listMultiSelection", + "when": "vscode-objectscript.connectActive && resourceScheme =~ /^isfs(-readonly)?$/ && resource =~ /project%3D/ && !(resourcePath =~ /^\\/?$/) && !listMultiSelection", "group": "objectscript_prj@2" }, { "command": "vscode-objectscript.removeItemsFromProject", - "when": "vscode-objectscript.connectActive && resourceScheme =~ /^isfs(-readonly)?$/ && resource =~ /project%3D/ && explorerResourceIsRoot && !listMultiSelection", + "when": "vscode-objectscript.connectActive && resourceScheme =~ /^isfs(-readonly)?$/ && resource =~ /project%3D/ && resourcePath =~ /^\\/?$/ && !listMultiSelection", "group": "objectscript_prj@2" }, { "command": "vscode-objectscript.modifyProjectMetadata", - "when": "vscode-objectscript.connectActive && resourceScheme =~ /^isfs(-readonly)?$/ && resource =~ /project%3D/ && explorerResourceIsRoot && !listMultiSelection", + "when": "vscode-objectscript.connectActive && resourceScheme =~ /^isfs(-readonly)?$/ && resource =~ /project%3D/ && resourcePath =~ /^\\/?$/ && !listMultiSelection", "group": "objectscript_prj@3" }, { diff --git a/src/api/index.ts b/src/api/index.ts index 38983986..3260f0d7 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -18,6 +18,7 @@ import { currentWorkspaceFolder, outputChannel, outputConsole } from "../utils"; const DEFAULT_API_VERSION = 1; const DEFAULT_SERVER_VERSION = "2016.2.0"; import * as Atelier from "./atelier"; +import { isfsConfig } from "../utils/FileProviderUtil"; // Map of the authRequest promises for each username@host:port target to avoid concurrency issues const authRequestMap = new Map>(); @@ -120,10 +121,8 @@ export class AtelierAPI { workspaceFolderName = parts[0]; namespace = parts[1]; } else { - const params = new URLSearchParams(wsOrFile.query); - if (params.has("ns") && params.get("ns") != "") { - namespace = params.get("ns"); - } + const { ns } = isfsConfig(wsOrFile); + if (ns) namespace = ns; } } else { const wsFolderOfFile = vscode.workspace.getWorkspaceFolder(wsOrFile); @@ -138,10 +137,6 @@ export class AtelierAPI { this.setConnection(workspaceFolderName || currentWorkspaceFolder(), namespace); } - public get enabled(): boolean { - return this._config.active; - } - public setNamespace(namespace: string): void { this.namespace = namespace; } diff --git a/src/commands/addServerNamespaceToWorkspace.ts b/src/commands/addServerNamespaceToWorkspace.ts index f91d1d53..bd92f9c3 100644 --- a/src/commands/addServerNamespaceToWorkspace.ts +++ b/src/commands/addServerNamespaceToWorkspace.ts @@ -11,6 +11,7 @@ import { } from "../extension"; import { cspAppsForUri, handleError, notIsfs } from "../utils"; import { pickProject } from "./project"; +import { isfsConfig, IsfsUriParam } from "../utils/FileProviderUtil"; /** * @param message The prefix of the message to show when the server manager API can't be found. @@ -143,9 +144,7 @@ export async function addServerNamespaceToWorkspace(resource?: vscode.Uri): Prom return; } // Generate the name - const params = new URLSearchParams(uri.query); - const project = params.get("project"); - const csp = params.has("csp"); + const { csp, project } = isfsConfig(uri); const name = `${project ? `${project} - ${serverName}:${namespace}` : !csp ? `${serverName}:${namespace}` : ["", "/"].includes(uri.path) ? `${serverName}:${namespace} web files` : `${serverName} (${uri.path})`}${ scheme == FILESYSTEM_READONLY_SCHEMA && !project ? " (read-only)" : "" }`; @@ -188,7 +187,7 @@ export async function getServerManagerApi(): Promise { /** Prompt the user to fill in the `path` and `query` of `uri`. */ async function modifyWsFolderUri(uri: vscode.Uri): Promise { if (notIsfs(uri)) return; - const params = new URLSearchParams(uri.query); + const { project, csp, system, generated, mapped, filter } = isfsConfig(uri); const api = new AtelierAPI(uri); // Prompt the user for the files to show @@ -211,9 +210,7 @@ async function modifyWsFolderUri(uri: vscode.Uri): Promise { switch (items[0].label) { @@ -258,7 +255,7 @@ async function modifyWsFolderUri(uri: vscode.Uri): Promise data.result.content.map((prj) => prj.Name.toLowerCase())); + .then((data) => data.result.content.map((prj) => prj.Name.toLowerCase())) + .catch((error) => { + handleError(error, `Failed to list projects on server '${api.serverId}'.`); + return; + }); + if (!taken) return; const name = await vscode.window.showInputBox({ prompt: "Enter a name for the new project", validateInput: (value: string) => { @@ -155,13 +161,14 @@ export async function deleteProject(node: ProjectNode | undefined): Promise api.setNamespace(node.namespace); project = node.label; } else { - // Have the user pick a server and namespace and a project - const picks = await pickServerAndNamespace(); - if (picks == undefined) { + // Have the user pick a server connection + const connUri = await getWsServerConnection(); + if (connUri == null) return; + if (connUri == undefined) { + handleError("No active server connections in the current workspace.", "'Delete Project' command failed."); return; } - const { serverName, namespace } = picks; - api = new AtelierAPI(vscode.Uri.parse(`isfs://${serverName}:${namespace}/`)); + api = new AtelierAPI(connUri); project = await pickProject(api); } if (project == undefined) { @@ -190,10 +197,10 @@ export async function deleteProject(node: ProjectNode | undefined): Promise projectsExplorerProvider.refresh(); // Ask the user if they want us to clean up an orphaned isfs folder - const prjFolderIdx = isfsFolderForProject(project, node ?? api.configName); + const prjFolderIdx = isfsFolderForProject(project, api); if (prjFolderIdx != -1) { const remove = await vscode.window.showInformationMessage( - `The current workspace contains a virtual folder linked to deleted project '${project}'. Remove this folder?`, + `The current workspace contains a server-side folder linked to deleted project '${project}'. Remove this folder?`, "Yes", "No" ); @@ -216,21 +223,19 @@ function addProjectItem( const add: ProjectItem[] = []; const remove: ProjectItem[] = []; - if (Type == "MAC" && items.findIndex((item) => item.Name.toLowerCase() == Name.toLowerCase()) == -1) { + if (Type == "MAC" && !items.some((item) => item.Name.toLowerCase() == Name.toLowerCase())) { add.push({ Name, Type }); } else if ( Type == "CLS" && // Class isn't included by name - items.findIndex((item) => item.Type == "CLS" && item.Name.toLowerCase() == Name.toLowerCase()) == -1 && + !items.some((item) => item.Type == "CLS" && item.Name.toLowerCase() == Name.toLowerCase()) && // Class's package isn't included - items.findIndex((item) => item.Type == "PKG" && Name.toLowerCase().startsWith(`${item.Name}.`.toLowerCase())) == -1 + !items.some((item) => item.Type == "PKG" && Name.toLowerCase().startsWith(`${item.Name}.`.toLowerCase())) ) { add.push({ Name, Type }); } else if ( Type == "PKG" && // Package or its superpackages aren't included - items.findIndex( - (item) => item.Type == "PKG" && `${Name.toLowerCase()}.`.startsWith(`${item.Name}.`.toLowerCase()) - ) == -1 + !items.some((item) => item.Type == "PKG" && `${Name.toLowerCase()}.`.startsWith(`${item.Name}.`.toLowerCase())) ) { add.push({ Name, Type }); // Remove any subpackages or classes that are in this package @@ -243,16 +248,14 @@ function addProjectItem( } else if ( Type == "CSP" && // File isn't included by name - items.findIndex((item) => item.Type == "CSP" && item.Name.toLowerCase() == Name.toLowerCase()) == -1 && + !items.some((item) => item.Type == "CSP" && item.Name.toLowerCase() == Name.toLowerCase()) && // File's directory isn't included - items.findIndex((item) => item.Type == "DIR" && Name.toLowerCase().startsWith(`${item.Name}/`.toLowerCase())) == -1 + !items.some((item) => item.Type == "DIR" && Name.toLowerCase().startsWith(`${item.Name}/`.toLowerCase())) ) { add.push({ Name, Type }); } else if ( Type == "DIR" && // Folder or its parents aren't included - items.findIndex( - (item) => item.Type == "DIR" && `${Name.toLowerCase()}/`.startsWith(`${item.Name}/`.toLowerCase()) - ) == -1 + !items.some((item) => item.Type == "DIR" && `${Name.toLowerCase()}/`.startsWith(`${item.Name}/`.toLowerCase())) ) { add.push({ Name, Type }); // Remove any subfolders or CSP items that are in this folder @@ -262,7 +265,7 @@ function addProjectItem( (item.Type == "CSP" || item.Type == "DIR") && item.Name.toLowerCase().startsWith(`${Name.toLowerCase()}/`) ) ); - } else if (Type == "OTH" && items.findIndex((item) => item.Name.toLowerCase() == Name.toLowerCase()) == -1) { + } else if (Type == "OTH" && !items.some((item) => item.Name.toLowerCase() == Name.toLowerCase())) { add.push({ Name, Type }); } @@ -277,15 +280,15 @@ function addProjectItem( export function removeProjectItem(Name: string, Type: string, items: ProjectItem[]): ProjectItem[] { const remove: ProjectItem[] = []; - if (Type == "MAC" && items.findIndex((item) => item.Name.toLowerCase() == Name.toLowerCase()) != -1) { + if (Type == "MAC" && items.some((item) => item.Name.toLowerCase() == Name.toLowerCase())) { remove.push({ Name, Type }); } else if ( Type == "CLS" && - items.findIndex((item) => item.Type == "CLS" && item.Name.toLowerCase() == Name.toLowerCase()) != -1 + items.some((item) => item.Type == "CLS" && item.Name.toLowerCase() == Name.toLowerCase()) ) { remove.push({ Name, Type }); } else if (Type == "PKG") { - if (items.findIndex((item) => item.Type == "PKG" && item.Name.toLowerCase() == Name.toLowerCase()) != -1) { + if (items.some((item) => item.Type == "PKG" && item.Name.toLowerCase() == Name.toLowerCase())) { // Package is included by name remove.push({ Name, Type }); } else { @@ -299,11 +302,11 @@ export function removeProjectItem(Name: string, Type: string, items: ProjectItem } } else if ( Type == "CSP" && - items.findIndex((item) => item.Type == "CSP" && item.Name.toLowerCase() == Name.toLowerCase()) != -1 + items.some((item) => item.Type == "CSP" && item.Name.toLowerCase() == Name.toLowerCase()) ) { remove.push({ Name, Type }); } else if (Type == "DIR") { - if (items.findIndex((item) => item.Type == "DIR" && item.Name.toLowerCase() == Name.toLowerCase()) != -1) { + if (items.some((item) => item.Type == "DIR" && item.Name.toLowerCase() == Name.toLowerCase())) { // Directory is included by name remove.push({ Name, Type }); } else { @@ -315,7 +318,7 @@ export function removeProjectItem(Name: string, Type: string, items: ProjectItem ) ); } - } else if (Type == "OTH" && items.findIndex((item) => item.Name.toLowerCase() == Name.toLowerCase()) != -1) { + } else if (Type == "OTH" && items.some((item) => item.Name.toLowerCase() == Name.toLowerCase())) { remove.push({ Name, Type: items.find((item) => item.Name.toLowerCase() == Name.toLowerCase())?.Type ?? Type }); } @@ -441,7 +444,7 @@ async function pickAdditions( data.result.content .map((i: string) => { const app = i.slice(1); - if (items.findIndex((pi) => pi.Type == "DIR" && pi.Name == app) == -1) { + if (!items.some((pi) => pi.Type == "DIR" && pi.Name == app)) { return { label: "$(folder) " + app, fullName: i, @@ -672,7 +675,10 @@ export async function modifyProject( nodeOrUri: NodeBase | vscode.Uri | undefined, type: "add" | "remove" ): Promise { - const args = await handleCommandArg(nodeOrUri); + const args = await handleCommandArg(nodeOrUri).catch((error) => { + handleError(error, `Failed to modify project.`); + return; + }); if (!args) return; const { node, api, project } = args; @@ -791,17 +797,22 @@ export async function modifyProject( } } else if ( nodeOrUri instanceof vscode.Uri && - vscode.workspace.workspaceFolders.findIndex((wf) => wf.uri.toString() == nodeOrUri.toString()) == -1 + !(vscode.workspace.workspaceFolders ?? []).some((wf) => wf.uri.toString() == nodeOrUri.toString()) ) { // Non-root item in files explorer if (nodeOrUri.path.includes(".")) { // This is a file, so remove it - const csp = isCSPFile(nodeOrUri); - const fileName = csp ? nodeOrUri.path : nodeOrUri.path.slice(1).replace(/\//g, "."); + const fileName = isfsDocumentName(nodeOrUri); let prjFileName = fileName.startsWith("/") ? fileName.slice(1) : fileName; const ext = prjFileName.split(".").pop().toLowerCase(); prjFileName = ext == "cls" ? prjFileName.slice(0, -4) : prjFileName; - const prjType = csp ? "CSP" : ext == "cls" ? "CLS" : ["mac", "int", "inc"].includes(ext) ? "MAC" : "OTH"; + const prjType = fileName.includes("/") + ? "CSP" + : ext == "cls" + ? "CLS" + : ["mac", "int", "inc"].includes(ext) + ? "MAC" + : "OTH"; remove.push(...removeProjectItem(prjFileName, prjType, items)); } else { // This is a directory, so remove everything in it @@ -896,7 +907,7 @@ export async function modifyProject( projectsExplorerProvider.refresh(); // Refresh the files explorer if there's an isfs folder for this project - if (node == undefined && isfsFolderForProject(project, node ?? api.configName) != -1) { + if (node == undefined && isfsFolderForProject(project, api) != -1) { vscode.commands.executeCommand("workbench.files.action.refreshFilesExplorer"); } } @@ -1007,50 +1018,38 @@ export async function compileProjectContents(node: ProjectNode): Promise { /** * Returns the index of the first isfs folder in this workspace that is linked to `project`. */ -function isfsFolderForProject(project: string, nodeOrWorkspaceFolder: string | NodeBase): number { - let conn: any; - if (typeof nodeOrWorkspaceFolder == "string") { - conn = config("conn", nodeOrWorkspaceFolder); - } else { - if (nodeOrWorkspaceFolder.workspaceFolder != undefined) { - conn = config("conn", nodeOrWorkspaceFolder.workspaceFolder); - } else { - const connConfig = new AtelierAPI(nodeOrWorkspaceFolder.workspaceFolderUri).config; - conn = { - ns: connConfig.ns, - server: connConfig.serverName, - host: connConfig.host, - port: connConfig.port, - }; - } - } - return vscode.workspace.workspaceFolders.findIndex((folder) => { - const params = new URLSearchParams(folder.uri.query); - if ( - filesystemSchemas.includes(folder.uri.scheme) && - compareConns(conn, config("conn", folder.name)) && - params.has("project") && - params.get("project") == project - ) { - return true; - } - return false; +function isfsFolderForProject(project: string, api: AtelierAPI): number { + if (!vscode.workspace.workspaceFolders) return -1; + const { https, host, port, pathPrefix } = api.config; + return vscode.workspace.workspaceFolders.findIndex((f) => { + const fApi = new AtelierAPI(f.uri); + const { https: fHttps, host: fHost, port: fPort, pathPrefix: fPP } = api.config; + return ( + filesystemSchemas.includes(f.uri.scheme) && + isfsConfig(f.uri).project == project && + fHost == host && + fPort == port && + fHttps == https && + fPP == pathPrefix && + api.ns == fApi.ns + ); }); } /** * Special version of `modifyProject()` that is only called when an `isfs` file in a project folder is created. */ -export async function addIsfsFileToProject( - project: string, - fileName: string, - csp: boolean, - api: AtelierAPI -): Promise { +export async function addIsfsFileToProject(project: string, fileName: string, api: AtelierAPI): Promise { let prjFileName = fileName.startsWith("/") ? fileName.slice(1) : fileName; const ext = prjFileName.split(".").pop().toLowerCase(); prjFileName = ext == "cls" ? prjFileName.slice(0, -4) : prjFileName; - const prjType = csp ? "CSP" : ext == "cls" ? "CLS" : ["mac", "int", "inc"].includes(ext) ? "MAC" : "OTH"; + const prjType = fileName.includes("/") + ? "CSP" + : ext == "cls" + ? "CLS" + : ["mac", "int", "inc"].includes(ext) + ? "MAC" + : "OTH"; const items: ProjectItem[] = await api .actionQuery("SELECT Name, Type FROM %Studio.Project_ProjectItemsList(?,?) WHERE Type != 'GBL'", [project, "1"]) .then((data) => data.result.content); @@ -1095,7 +1094,7 @@ export async function addIsfsFileToProject( export function addWorkspaceFolderForProject(node: ProjectNode): void { // Check if an isfs folder already exists for this project - const idx = isfsFolderForProject(node.label, node); + const idx = isfsFolderForProject(node.label, new AtelierAPI(node.workspaceFolderUri)); // If not, create one if (idx != -1) { vscode.window.showWarningMessage(`A workspace folder for this project already exists.`, "Dismiss"); @@ -1129,28 +1128,27 @@ async function handleCommandArg( } else if (nodeOrUri instanceof vscode.Uri) { // Called from files explorer api = new AtelierAPI(nodeOrUri); - project = new URLSearchParams(nodeOrUri.query).get("project"); + project = isfsConfig(nodeOrUri).project; } else { // Function was called from the command palette so there's no first argument - // Have the user pick a server and namespace - const picks = await pickServerAndNamespace(); - if (picks == undefined) { - return; - } - const { serverName, namespace } = picks; - api = new AtelierAPI(vscode.Uri.parse(`isfs://${serverName}:${namespace}/`)); + // Have the user pick a server connection + const connUri = await getWsServerConnection(); + if (connUri == null) return; + if (connUri == undefined) throw "No active server connections in the current workspace."; + api = new AtelierAPI(connUri); } - if (project === undefined) { + if (!project) { project = await pickProject(api); - if (project === undefined) { - return; - } + if (!project) return; } return { node, api, project }; } export async function modifyProjectMetadata(nodeOrUri: NodeBase | vscode.Uri | undefined): Promise { - const args = await handleCommandArg(nodeOrUri); + const args = await handleCommandArg(nodeOrUri).catch((error) => { + handleError(error, `Failed to modify project metadata.`); + return; + }); if (!args) return; const { api, project } = args; diff --git a/src/commands/serverActions.ts b/src/commands/serverActions.ts index 9e4711bb..3fe371df 100644 --- a/src/commands/serverActions.ts +++ b/src/commands/serverActions.ts @@ -19,6 +19,7 @@ import { import { mainCommandMenu, mainSourceControlMenu } from "./studio"; import { AtelierAPI } from "../api"; import { getCSPToken } from "../utils/getCSPToken"; +import { isfsConfig } from "../utils/FileProviderUtil"; type ServerAction = { detail: string; id: string; label: string; rawLink?: string }; export async function serverActions(): Promise { @@ -137,7 +138,7 @@ export async function serverActions(): Promise { const classRef = `/csp/documatic/%25CSP.Documatic.cls?LIBRARY=${nsEncoded}${ classname ? "&CLASSNAME=" + classnameEncoded : "" }`; - const project = new URLSearchParams(wsUri?.query).get("project") || ""; + const project = wsUri ? isfsConfig(wsUri).project : ""; let extraLinks = 0; for (const title in links) { const rawLink = String(links[title]); @@ -190,7 +191,7 @@ export async function serverActions(): Promise { actions.push({ id: "openStudioAddin", label: "Open Studio Add-in...", - detail: "Select a Studio Add-in to open", + detail: "Pick a Studio Add-in to open", }); if ( (!vscode.window.activeTextEditor && wsUri && wsUri.scheme == FILESYSTEM_SCHEMA) || @@ -199,7 +200,7 @@ export async function serverActions(): Promise { actions.push({ id: "serverSourceControlMenu", label: "Server Source Control...", - detail: "Pick server-side source control action", + detail: "Pick a server-side source control action to execute", }); } if ( @@ -209,12 +210,12 @@ export async function serverActions(): Promise { actions.push({ id: "serverCommandMenu", label: "Server Command Menu...", - detail: "Pick server-side command", + detail: "Pick a server-side command to execute", }); } return vscode.window .showQuickPick(actions, { - placeHolder: `Select action for server: ${connInfo}`, + placeHolder: `Pick action to perform for server ${connInfo}`, }) .then(connectionActionsHandler) .then(async (action) => { @@ -243,7 +244,7 @@ export async function serverActions(): Promise { }); if (addins != undefined) { const addin = await vscode.window.showQuickPick(addins, { - placeHolder: `Select Studio Add-In for server: ${connInfo}`, + placeHolder: `Pick a Studio Add-In to open for server: ${connInfo}`, }); if (addin) { const token = await getCSPToken(api, addin.id); diff --git a/src/commands/studio.ts b/src/commands/studio.ts index 448eb0bf..72108ee4 100644 --- a/src/commands/studio.ts +++ b/src/commands/studio.ts @@ -1,9 +1,10 @@ import * as vscode from "vscode"; import { AtelierAPI } from "../api"; import { iscIcon } from "../extension"; -import { outputChannel, outputConsole, getServerName, notIsfs, handleError, openCustomEditors } from "../utils"; +import { outputChannel, outputConsole, notIsfs, handleError, openCustomEditors } from "../utils"; import { DocumentContentProvider } from "../providers/DocumentContentProvider"; import { UserAction } from "../api/atelier"; +import { isfsDocumentName } from "../providers/FileSystemProvider/FileSystemProvider"; export enum OtherStudioAction { AttemptedEdit = 0, @@ -66,7 +67,7 @@ export class StudioActions { public constructor(uri?: vscode.Uri) { if (uri instanceof vscode.Uri) { this.uri = uri; - this.name = getServerName(uri); + this.name = isfsDocumentName(uri, undefined, true); this.api = new AtelierAPI(uri); } else { this.api = new AtelierAPI(); @@ -429,9 +430,15 @@ export class StudioActions { .then((data) => data.result.content) .then((menus) => this.prepareMenuItems(menus, sourceControl)) .then((menuItems) => { + const noun = sourceControl ? "source control action" : "command"; + const suffix = this.name ? ` on ${this.name}` : ""; + if (menuItems.length == 0) { + vscode.window.showInformationMessage(`There are no server-side ${noun}s to execute${suffix}.`, "Dismiss"); + return; + } return vscode.window.showQuickPick(menuItems, { canPickMany: false, - placeHolder: `Pick server-side command to perform${this.name ? " on " + this.name : ""}`, + placeHolder: `Pick a server-side ${noun} to execute${suffix}`, }); }) .then((action) => this.userAction(action)); diff --git a/src/commands/unitTest.ts b/src/commands/unitTest.ts index 7a8b61a3..4ac4e5f9 100644 --- a/src/commands/unitTest.ts +++ b/src/commands/unitTest.ts @@ -9,7 +9,7 @@ import { stripClassMemberNameQuotes, uriIsParentOf, } from "../utils"; -import { fileSpecFromURI } from "../utils/FileProviderUtil"; +import { fileSpecFromURI, isfsConfig } from "../utils/FileProviderUtil"; import { AtelierAPI } from "../api"; import { DocumentContentProvider } from "../providers/DocumentContentProvider"; @@ -171,6 +171,7 @@ function createRootItemsForWorkspaceFolder( ): vscode.TestItem[] { let newItems: vscode.TestItem[] = []; const api = new AtelierAPI(folder.uri); + const { csp } = isfsConfig(folder.uri); // Must have an active server connection to a non-%SYS namespace and Atelier API version 8 or above const errorMsg = !api.active || api.ns == "" @@ -179,8 +180,7 @@ function createRootItemsForWorkspaceFolder( ? "Connected to the %SYS namespace" : api.config.apiVersion < 8 ? "Must be connected to InterSystems IRIS version 2023.3 or above" - : filesystemSchemas.includes(folder.uri.scheme) && - ["", "1"].includes(new URLSearchParams(folder.uri.query).get("csp")) + : filesystemSchemas.includes(folder.uri.scheme) && csp ? "Web application folder" : undefined; let itemUris: vscode.Uri[]; @@ -257,6 +257,7 @@ function replaceRootTestItems(testController: vscode.TestController): void { async function childrenForServerSideFolderItem( item: vscode.TestItem ): Promise>> { + const { project, system, generated, mapped } = isfsConfig(item.uri); let query: string; let parameters: string[]; let folder = !item.uri.path.endsWith("/") ? item.uri.path + "/" : item.uri.path; @@ -267,25 +268,26 @@ async function childrenForServerSideFolderItem( } folder = folder.replace(/\//g, "."); const folderLen = String(folder.length + 1); // Need the + 1 because SUBSTR is 1 indexed - const params = new URLSearchParams(item.uri.query); const api = new AtelierAPI(item.uri); - if (params.has("project")) { + if (project) { query = "SELECT DISTINCT CASE " + - "WHEN $LENGTH(SUBSTR(Name,?),'.') > 1 THEN $PIECE(SUBSTR(Name,?),'.') " + - "ELSE SUBSTR(Name,?)||'.cls' END Name " + - "FROM %Studio.Project_ProjectItemsList(?) " + - "WHERE Type = 'CLS' AND Name %STARTSWITH ? AND " + - "Name IN (SELECT Name FROM %Dictionary.ClassDefinition_SubclassOf('%UnitTest.TestCase','@'))"; - parameters = [folderLen, folderLen, folderLen, params.get("project"), folder]; + "WHEN $LENGTH(SUBSTR(pil.Name,?),'.') > 1 THEN $PIECE(SUBSTR(pil.Name,?),'.') " + + "ELSE SUBSTR(pil.Name,?)||'.cls' END Name " + + "FROM %Studio.Project_ProjectItemsList(?) AS pil " + + "JOIN %Dictionary.ClassDefinition_SubclassOf('%UnitTest.TestCase','@') AS sub " + + "ON pil.Name = sub.Name " + + "WHERE pil.Type = 'CLS' AND pil.Name %STARTSWITH ?"; + parameters = [folderLen, folderLen, folderLen, project, folder]; } else { query = "SELECT DISTINCT CASE " + - "WHEN $LENGTH(SUBSTR(Name,?),'.') > 2 THEN $PIECE(SUBSTR(Name,?),'.') " + - "ELSE SUBSTR(Name,?) END Name " + - "FROM %Library.RoutineMgr_StudioOpenDialog(?,?,?,?,?,?,?,?,?,?) " + - "WHERE Name %STARTSWITH ? AND " + - "Name IN (SELECT Name||'.cls' FROM %Dictionary.ClassDefinition_SubclassOf('%UnitTest.TestCase','@'))"; + "WHEN $LENGTH(SUBSTR(sod.Name,?),'.') > 2 THEN $PIECE(SUBSTR(sod.Name,?),'.') " + + "ELSE SUBSTR(sod.Name,?) END Name " + + "FROM %Library.RoutineMgr_StudioOpenDialog(?,?,?,?,?,?,?,?,?,?) AS sod " + + "JOIN %Dictionary.ClassDefinition_SubclassOf('%UnitTest.TestCase','@') AS sub " + + "ON sod.Name = sub.Name||'.cls' " + + "WHERE sod.Name %STARTSWITH ?"; parameters = [ folderLen, folderLen, @@ -293,13 +295,13 @@ async function childrenForServerSideFolderItem( fileSpecFromURI(item.uri), "1", "1", - params.has("system") && params.get("system").length ? params.get("system") : "0", + system ? "1" : "0", "1", "0", - params.has("generated") && params.get("generated").length ? params.get("generated") : "0", + generated ? "1" : "0", "", "0", - params.has("mapped") && params.get("mapped") == "0" ? "0" : "1", + mapped ? "1" : "0", folder, ]; } diff --git a/src/debug/debugSession.ts b/src/debug/debugSession.ts index 310f6425..c7f8ab41 100644 --- a/src/debug/debugSession.ts +++ b/src/debug/debugSession.ts @@ -26,6 +26,7 @@ import * as xdebug from "./xdebugConnection"; import { lsExtensionId, schemas } from "../extension"; import { DocumentContentProvider } from "../providers/DocumentContentProvider"; import { formatPropertyValue } from "./utils"; +import { isfsConfig } from "../utils/FileProviderUtil"; interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArguments { /** An absolute path to the "program" to debug. */ @@ -46,12 +47,10 @@ interface AttachRequestArguments extends DebugProtocol.AttachRequestArguments { /** converts a uri from VS Code to a server-side XDebug file URI with respect to source root settings */ async function convertClientPathToDebugger(uri: vscode.Uri, namespace: string): Promise { const { scheme, path } = uri; - const params = new URLSearchParams(uri.query); let fileName: string; if (scheme && schemas.includes(scheme)) { - if (params.has("ns") && params.get("ns") !== "") { - namespace = params.get("ns"); - } + const { ns } = isfsConfig(uri); + if (ns) namespace = ns; fileName = path.slice(1).replace(/\//g, "."); } else { fileName = currentFileFromContent(uri, await getFileText(uri))?.name; diff --git a/src/providers/DocumentContentProvider.ts b/src/providers/DocumentContentProvider.ts index 09036650..d65cf2ec 100644 --- a/src/providers/DocumentContentProvider.ts +++ b/src/providers/DocumentContentProvider.ts @@ -2,11 +2,11 @@ import * as fs from "fs"; import * as path from "path"; import * as vscode from "vscode"; import { AtelierAPI } from "../api"; - import { getFileName } from "../commands/export"; import { config, FILESYSTEM_SCHEMA, FILESYSTEM_READONLY_SCHEMA, OBJECTSCRIPT_FILE_SCHEMA } from "../extension"; import { currentWorkspaceFolder, isClassOrRtn, notIsfs, uriOfWorkspaceFolder } from "../utils"; import { getUrisForDocument } from "../utils/documentIndex"; +import { isfsConfig, IsfsUriParam } from "../utils/FileProviderUtil"; export function compareConns( conn1: { ns: any; server: any; host: any; port: any; "docker-compose": any }, @@ -143,31 +143,25 @@ export class DocumentContentProvider implements vscode.TextDocumentContentProvid if (authorityParts.length === 2 && namespace?.toLowerCase() === authorityParts[1]) { namespace = ""; } - const fileExt = name.split(".").pop(); - const fileName = name - .split(".") - .slice(0, -1) - .join(/cls|mac|int|inc/i.test(fileExt) ? "/" : "."); - if (/.\.G?[1-9]\.int$/i.test(name)) { + const params = new URLSearchParams(wFolderUri.query); + const cspParam = params.has(IsfsUriParam.CSP) && ["", "1"].includes(params.get(IsfsUriParam.CSP)); + const lastDot = name.lastIndexOf("."); + let uriPath = isCsp ? name : name.slice(0, lastDot).replace(/\./g, "/") + "." + name.slice(lastDot + 1); + if (!isCsp && /.\.G?[1-9]\.int$/i.test(name)) { // This is a generated INT file - name = - fileName.slice(0, fileName.lastIndexOf("/")) + - "." + - fileName.slice(fileName.lastIndexOf("/") + 1) + - "." + - fileExt; - } else { - name = fileName + "." + fileExt; + const lastSlash = uriPath.lastIndexOf("/"); + uriPath = uriPath.slice(0, lastSlash) + "." + uriPath.slice(lastSlash + 1); } uri = wFolderUri.with({ - path: !name.startsWith("/") ? `/${name}` : name, + path: !uriPath.startsWith("/") ? `/${uriPath}` : uriPath, }); vfs = true; scheme = wFolderUri.scheme; - // If this is a class or routine, remove the CSP query param if it's present - if (uri.query === "csp" && /cls|mac|int|inc/i.test(fileExt)) { + // If this is not a CSP file, remove the CSP query param if it's present + if (cspParam && !isCsp) { + params.delete(IsfsUriParam.CSP); uri = uri.with({ - query: "", + query: params.toString(), }); } } else { @@ -221,27 +215,27 @@ export class DocumentContentProvider implements vscode.TextDocumentContentProvid } const params = new URLSearchParams(uri.query); // Don't modify the query params if project is present - if (!params.has("project")) { + if (!params.has(IsfsUriParam.Project)) { if (namespace && namespace !== "") { if (isCsp) { - if (params.has("csp")) { - params.set("ns", namespace); + if (params.has(IsfsUriParam.CSP)) { + params.set(IsfsUriParam.NS, namespace); uri = uri.with({ query: params.toString(), }); } else { uri = uri.with({ - query: `ns=${namespace}&csp=1`, + query: `${IsfsUriParam.NS}=${namespace}&${IsfsUriParam.CSP}=1`, }); } } else { uri = uri.with({ - query: `ns=${namespace}`, + query: `${IsfsUriParam.NS}=${namespace}`, }); } - } else if (isCsp && !params.has("csp")) { + } else if (isCsp && !params.has(IsfsUriParam.CSP)) { uri = uri.with({ - query: "csp=1", + query: `${IsfsUriParam.CSP}=1`, }); } } @@ -251,14 +245,10 @@ export class DocumentContentProvider implements vscode.TextDocumentContentProvid public async provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): Promise { const api = new AtelierAPI(uri); - const params = new URLSearchParams(uri.query); - const fileName = - params.has("csp") && ["", "1"].includes(params.get("csp")) - ? uri.path.slice(1) - : uri.path.split("/").slice(1).join("."); - if (params.has("ns") && params.get("ns") != "") { - api.setNamespace(params.get("ns")); - } + // Even though this is technically a "objectscript" Uri, the query parameters are the same as "isfs" + const { csp, ns } = isfsConfig(uri); + const fileName = csp ? uri.path.slice(1) : uri.path.split("/").slice(1).join("."); + if (ns) api.setNamespace(ns); const data = await api.getDoc(fileName); if (Buffer.isBuffer(data.result.content)) { return "\nThis is a binary file.\n\nTo access its contents, export it to the local file system."; diff --git a/src/providers/FileSystemProvider/Directory.ts b/src/providers/FileSystemProvider/Directory.ts deleted file mode 100644 index 05edd15f..00000000 --- a/src/providers/FileSystemProvider/Directory.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as vscode from "vscode"; - -import { File } from "./File"; -export class Directory implements vscode.FileStat { - public name: string; - public fullName: string; - public type: vscode.FileType; - public ctime: number; - public mtime: number; - public size: number; - public entries: Map; - public constructor(name: string, fullName: string) { - this.name = name; - this.fullName = fullName; - this.type = vscode.FileType.Directory; - this.ctime = Date.now(); - this.mtime = Date.now(); - this.size = 0; - this.entries = new Map(); - } -} diff --git a/src/providers/FileSystemProvider/File.ts b/src/providers/FileSystemProvider/File.ts deleted file mode 100644 index fd4776c5..00000000 --- a/src/providers/FileSystemProvider/File.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as vscode from "vscode"; - -export class File implements vscode.FileStat { - public type: vscode.FileType; - public ctime: number; - public mtime: number; - public size: number; - public permissions?: vscode.FilePermission; - public fileName: string; - public name: string; - public data?: Uint8Array; - public constructor(name: string, fileName: string, ts: string, size: number, data: string | Buffer) { - this.type = vscode.FileType.File; - this.ctime = Number(new Date(ts + "Z")); - this.mtime = this.ctime; - this.size = size; - this.fileName = fileName; - this.name = name; - this.data = typeof data === "string" ? Buffer.from(data) : data; - } -} diff --git a/src/providers/FileSystemProvider/FileSearchProvider.ts b/src/providers/FileSystemProvider/FileSearchProvider.ts index 39af8260..5f519c00 100644 --- a/src/providers/FileSystemProvider/FileSearchProvider.ts +++ b/src/providers/FileSystemProvider/FileSearchProvider.ts @@ -1,85 +1,63 @@ import * as vscode from "vscode"; -import { projectContentsFromUri, studioOpenDialogFromURI } from "../../utils/FileProviderUtil"; -import { notNull } from "../../utils"; +import { isfsConfig, projectContentsFromUri, studioOpenDialogFromURI } from "../../utils/FileProviderUtil"; +import { notNull, queryToFuzzyLike } from "../../utils"; import { DocumentContentProvider } from "../DocumentContentProvider"; import { ProjectItem } from "../../commands/project"; export class FileSearchProvider implements vscode.FileSearchProvider { - /** - * Provide the set of files that match a certain file path pattern. - * @param query The parameters for this query. - * @param options A set of options to consider while searching files. - * @param token A cancellation token. - */ public async provideFileSearchResults( query: vscode.FileSearchQuery, options: vscode.FileSearchOptions, token: vscode.CancellationToken ): Promise { let counter = 0; - let pattern = query.pattern.charAt(0) == "/" ? query.pattern.slice(1) : query.pattern; - - // Drop a leading **/ from the glob pattern if it exists. This gets added by Find widget of Explorer tree (non-fuzzy mode), which since 1.94 uses FileSearchProvider - if (pattern.startsWith("**/")) { + // Replace all back slashes with forward slashes + let pattern = query.pattern.replace(/\\/g, "/"); + if (pattern.startsWith("/")) { + // Remove all leading slashes + pattern = pattern.replace(/^\/+/, ""); + } else if (pattern.startsWith("**/")) { + // Remove a leading globstar from the pattern. + // The leading globstar gets added by Find widget of Explorer tree (non-fuzzy mode), which since 1.94 uses FileSearchProvider pattern = pattern.slice(3); - } else if (pattern.length) { - // Do a fuzzy search - pattern = "*" + pattern.split("").join("*") + "*"; } - const params = new URLSearchParams(options.folder.query); - const csp = params.has("csp") && ["", "1"].includes(params.get("csp")); - if (params.has("project") && params.get("project").length) { - const patternRegex = new RegExp(`.*${pattern}.*`.replace(/\.|\//g, "[./]"), "i"); + const { csp, project } = isfsConfig(options.folder); + if (project) { + // Create a fuzzy match regex to do the filtering here + let regexStr = ".*"; + for (const c of pattern) regexStr += `${[".", "/"].includes(c) ? "[./]" : c}.*`; + const patternRegex = new RegExp(regexStr, "i"); + if (token.isCancellationRequested) return; return projectContentsFromUri(options.folder, true).then((docs) => docs - .map((doc: ProjectItem) => { - if (token.isCancellationRequested) { - return null; - } - if (pattern.length && !patternRegex.test(doc.Name)) { - // The document didn't pass the filter - return null; - } - if (!options.maxResults || ++counter <= options.maxResults) { - return DocumentContentProvider.getUri(doc.Name, "", "", true, options.folder); - } else { - return null; - } - }) + .map((doc: ProjectItem) => + !token.isCancellationRequested && + // The document matches the query + (!pattern.length || patternRegex.test(doc.Name)) && + // We haven't hit the max number of results + (!options.maxResults || ++counter <= options.maxResults) + ? DocumentContentProvider.getUri(doc.Name, "", "", true, options.folder) + : null + ) .filter(notNull) ); } - // When this is called without a query.pattern, every file is supposed to be returned, so do not provide a filter - let filter = ""; - if (pattern.length) { - pattern = !csp ? pattern.replace(/\//g, ".") : pattern; - filter = `Name LIKE '%${pattern - // Escape % or _ characters - .replace(/(_|%|\\)/g, "\\$1") - // Change glob syntax to SQL LIKE syntax - .replace(/\*/g, "%") - .replace(/\?/g, "_")}%' ESCAPE '\\'`; - } - if (token.isCancellationRequested) { - return; - } - return studioOpenDialogFromURI(options.folder, { flat: true, filter: filter }) - .then((data) => { - return data.result.content; - }) - .then((data: { Name: string; Type: number }[]) => { - return data - .map((item) => { - if (token.isCancellationRequested) { - return null; - } - if (!options.maxResults || ++counter <= options.maxResults) { - return DocumentContentProvider.getUri(item.Name, "", "", true, options.folder); - } else { - return null; - } - }) - .filter(notNull); - }); + // When this is called without a query.pattern every file is supposed to be returned, so do not provide a filter + const likePattern = queryToFuzzyLike(pattern); + const filter = pattern.length + ? `Name LIKE '${!csp ? likePattern.replace(/\//g, ".") : likePattern}' ESCAPE '\\'` + : ""; + if (token.isCancellationRequested) return; + return studioOpenDialogFromURI(options.folder, { flat: true, filter }).then((data) => + data.result.content + .map((doc: { Name: string; Type: number }) => + !token.isCancellationRequested && + // We haven't hit the max number of results + (!options.maxResults || ++counter <= options.maxResults) + ? DocumentContentProvider.getUri(doc.Name, "", "", true, options.folder) + : null + ) + .filter(notNull) + ); } } diff --git a/src/providers/FileSystemProvider/FileSystemProvider.ts b/src/providers/FileSystemProvider/FileSystemProvider.ts index 8a0011c6..f4ef26c6 100644 --- a/src/providers/FileSystemProvider/FileSystemProvider.ts +++ b/src/providers/FileSystemProvider/FileSystemProvider.ts @@ -2,12 +2,11 @@ import * as path from "path"; import * as vscode from "vscode"; import { isText } from "istextorbinary"; import { AtelierAPI } from "../../api"; -import { Directory } from "./Directory"; -import { File } from "./File"; import { fireOtherStudioAction, OtherStudioAction, StudioActions } from "../../commands/studio"; -import { projectContentsFromUri, studioOpenDialogFromURI } from "../../utils/FileProviderUtil"; +import { isfsConfig, projectContentsFromUri, studioOpenDialogFromURI } from "../../utils/FileProviderUtil"; import { classNameRegex, + cspAppsForUri, isClassDeployed, notIsfs, notNull, @@ -19,18 +18,50 @@ import { base64EncodeContent, openCustomEditors, } from "../../utils"; -import { - config, - FILESYSTEM_READONLY_SCHEMA, - FILESYSTEM_SCHEMA, - intLangId, - macLangId, - workspaceState, -} from "../../extension"; +import { FILESYSTEM_READONLY_SCHEMA, FILESYSTEM_SCHEMA, intLangId, macLangId, workspaceState } from "../../extension"; import { addIsfsFileToProject, modifyProject } from "../../commands/project"; import { DocumentContentProvider } from "../DocumentContentProvider"; import { Document, UserAction } from "../../api/atelier"; +class File implements vscode.FileStat { + public type: vscode.FileType; + public ctime: number; + public mtime: number; + public size: number; + public permissions?: vscode.FilePermission; + public fileName: string; + public name: string; + public data?: Uint8Array; + public constructor(name: string, fileName: string, ts: string, size: number, data: string | Buffer) { + this.type = vscode.FileType.File; + this.ctime = Number(new Date(ts + "Z")); + this.mtime = this.ctime; + this.size = size; + this.fileName = fileName; + this.name = name; + this.data = typeof data === "string" ? Buffer.from(data) : data; + } +} + +class Directory implements vscode.FileStat { + public name: string; + public fullName: string; + public type: vscode.FileType; + public ctime: number; + public mtime: number; + public size: number; + public entries: Map; + public constructor(name: string, fullName: string) { + this.name = name; + this.fullName = fullName; + this.type = vscode.FileType.Directory; + this.ctime = Date.now(); + this.mtime = Date.now(); + this.size = 0; + this.entries = new Map(); + } +} + type Entry = File | Directory; export function generateFileContent( @@ -125,13 +156,10 @@ export function generateFileContent( */ const cspFilesInProjectFolder: Map = new Map(); -/** - * Check if this file is a web application file. - */ -export function isCSPFile(uri: vscode.Uri): boolean { - const params = new URLSearchParams(uri.query); - let csp = params.has("csp") && ["", "1"].includes(params.get("csp")); - if (params.has("project") && params.get("project").length) { +/** Returns `true` if `uri` is a web application file */ +export function isCSP(uri: vscode.Uri): boolean { + const { csp, project } = isfsConfig(uri); + if (project) { // Projects can contain both CSP and non-CSP files // Read the cache of found CSP files to determine if this is one const parent = uri @@ -139,38 +167,48 @@ export function isCSPFile(uri: vscode.Uri): boolean { path: path.dirname(uri.path), }) .toString(); - csp = cspFilesInProjectFolder.has(parent) && cspFilesInProjectFolder.get(parent).includes(path.basename(uri.path)); - if (!csp) { - // Read the parent directory and file is not CSP OR haven't read the parent directory yet - // Use the file extension to guess if it's a web app file - const additionalExts: string[] = config("projects.webAppFileExtensions", workspaceFolderOfUri(uri)); - csp = [ - "csp", - "csr", - "ts", - "js", - "css", - "scss", - "sass", - "less", - "html", - "json", - "md", - "markdown", - "png", - "svg", - "jpeg", - "jpg", - "ico", - "xml", - "txt", - ...additionalExts, - ].includes(uri.path.split(".").pop().toLowerCase()); + if (cspFilesInProjectFolder.has(parent) && cspFilesInProjectFolder.get(parent).includes(path.basename(uri.path))) { + return true; } + // Read the parent directory and file is not CSP OR haven't read the parent directory yet + // Use the file extension to guess if it's a web app file + const additionalExts: string[] = vscode.workspace + .getConfiguration("objectscript.projects", uri) + .get("webAppFileExtensions"); + return [ + "csp", + "csr", + "ts", + "js", + "css", + "scss", + "sass", + "less", + "html", + "json", + "md", + "markdown", + "png", + "svg", + "jpeg", + "jpg", + "ico", + "xml", + "txt", + ...additionalExts, + ].includes(uri.path.split(".").pop().toLowerCase()); } return csp; } +/** Get the document name of the file in `uri`. */ +export function isfsDocumentName(uri: vscode.Uri, csp?: boolean, pkg = false): string { + if (csp == undefined) csp = isCSP(uri); + const doc = csp ? uri.path : uri.path.slice(1).replace(/\//g, "."); + // Add the .PKG extension to non-web folders if called from StudioActions + return pkg && !csp && !doc.split("/").pop().includes(".") ? `${doc}.PKG` : doc; +} + export class FileSystemProvider implements vscode.FileSystemProvider { private superRoot = new Directory("", ""); @@ -222,10 +260,9 @@ export class FileSystemProvider implements vscode.FileSystemProvider { return entryPromise; } - // if (result instanceof File) { const api = new AtelierAPI(uri); - const serverName = isCSPFile(uri) ? uri.path : uri.path.slice(1).replace(/\//g, "."); + const serverName = isfsDocumentName(uri); if (serverName.slice(-4).toLowerCase() == ".cls") { if (await isClassDeployed(serverName, api)) { result.permissions |= vscode.FilePermission.Readonly; @@ -250,18 +287,14 @@ export class FileSystemProvider implements vscode.FileSystemProvider { uri = redirectDotvscodeRoot(uri); const parent = await this._lookupAsDirectory(uri); const api = new AtelierAPI(uri); - if (!api.active) { - throw vscode.FileSystemError.Unavailable(`${uri.toString()} is unavailable`); - } - const params = new URLSearchParams(uri.query); - if (params.has("project") && params.get("project").length) { + if (!api.active) throw vscode.FileSystemError.Unavailable(uri); + const { csp, project } = isfsConfig(uri); + if (project) { if (["", "/"].includes(uri.path)) { // Technically a project is a "document", so tell the server that we're opening it - await new StudioActions() - .fireProjectUserAction(api, params.get("project"), OtherStudioAction.OpenedDocument) - .catch(() => { - // Swallow error because showing it is more disruptive than using a potentially outdated project definition - }); + await new StudioActions().fireProjectUserAction(api, project, OtherStudioAction.OpenedDocument).catch(() => { + // Swallow error because showing it is more disruptive than using a potentially outdated project definition + }); } // Get all items in the project @@ -298,7 +331,6 @@ export class FileSystemProvider implements vscode.FileSystemProvider { }) ); } - const csp = params.has("csp") && ["", "1"].includes(params.get("csp")); const folder = !csp ? uri.path.replace(/\/$/, "").replace(/\//g, ".") : uri.path === "/" @@ -306,8 +338,9 @@ export class FileSystemProvider implements vscode.FileSystemProvider { : uri.path.endsWith("/") ? uri.path : uri.path + "/"; - // get all web apps that have a filepath (Studio dialog used below returns REST ones too) - const cspApps = csp ? await api.getCSPApps().then((data) => data.result.content || []) : []; + + // Get all web apps that have a path (StudioOpenDialog returns all web apps) + const cspApps = csp ? cspAppsForUri(uri) : []; const cspSubfolderMap = new Map(); const prefix = folder === "" ? "/" : folder; for (const app of cspApps) { @@ -410,8 +443,8 @@ export class FileSystemProvider implements vscode.FileSystemProvider { if (uri.path.startsWith("/.")) { throw vscode.FileSystemError.NoPermissions("dot-folders not supported by server"); } - const csp = isCSPFile(uri); - const fileName = csp ? uri.path : uri.path.slice(1).replace(/\//g, "."); + const csp = isCSP(uri); + const fileName = isfsDocumentName(uri, csp); if (fileName.startsWith(".")) { return; } @@ -505,10 +538,10 @@ export class FileSystemProvider implements vscode.FileSystemProvider { fireOtherStudioAction(OtherStudioAction.CreatedNewDocument, uri, data.result.ext[0]); fireOtherStudioAction(OtherStudioAction.FirstTimeDocumentSave, uri, data.result.ext[1]); } - const params = new URLSearchParams(uri.query); - if (params.has("project") && params.get("project").length) { + const { project } = isfsConfig(uri); + if (project) { // Add this document to the project if required - addIsfsFileToProject(params.get("project"), fileName, csp, api); + addIsfsFileToProject(project, fileName, api); } // Create an entry in our cache for the document this._lookupAsFile(uri); @@ -574,14 +607,9 @@ export class FileSystemProvider implements vscode.FileSystemProvider { public async delete(uri: vscode.Uri, options: { recursive: boolean }): Promise { uri = redirectDotvscodeRoot(uri); - const csp = isCSPFile(uri); - const fileName = csp ? uri.path : uri.path.slice(1).replace(/\//g, "."); - const params = new URLSearchParams(uri.query); - const project = params.has("project") && params.get("project").length > 0; + const { project } = isfsConfig(uri); + const csp = isCSP(uri); const api = new AtelierAPI(uri); - if (fileName.startsWith(".")) { - return; - } if (await this._lookup(uri, true).then((entry) => entry instanceof Directory)) { // Get the list of documents to delete let toDeletePromise: Promise; @@ -599,7 +627,8 @@ export class FileSystemProvider implements vscode.FileSystemProvider { if (options.recursive || project) { return entry.Name; } else if (entry.Name.includes(".")) { - return csp ? uri.path + entry.Name : uri.path.slice(1).replace(/\//g, ".") + entry.Name; + const uriPath = uri.path.endsWith("/") ? uri.path : uri.path + "/"; + return (csp ? uriPath : uriPath.slice(1).replace(/\//g, ".")) + entry.Name; } return null; }) @@ -617,8 +646,8 @@ export class FileSystemProvider implements vscode.FileSystemProvider { this.processDeletedDoc( doc, DocumentContentProvider.getUri(doc.name, undefined, undefined, true, uri), - csp, - project + doc.name.includes("/"), + project.length > 0 ); } else { // The document was not deleted, so log the error @@ -639,26 +668,29 @@ export class FileSystemProvider implements vscode.FileSystemProvider { ); } }); - } - return api.deleteDoc(fileName).then( - (response) => { - this.processDeletedDoc(response.result, uri, csp, project); - if (project) { - // Remove this document from the project if required - modifyProject(uri, "remove"); + } else { + const fileName = isfsDocumentName(uri, csp); + if (fileName.startsWith(".")) return; + return api.deleteDoc(fileName).then( + (response) => { + this.processDeletedDoc(response.result, uri, csp, project.length > 0); + if (project) { + // Remove this document from the project if required + modifyProject(uri, "remove"); + } + }, + (error) => { + handleError(error); + throw new vscode.FileSystemError( + `Failed to delete file '${fileName}'. Check the 'ObjectScript' Output channel for details.` + ); } - }, - (error) => { - handleError(error); - throw new vscode.FileSystemError( - `Failed to delete file '${fileName}'. Check the 'ObjectScript' Output channel for details.` - ); - } - ); + ); + } } public async rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean }): Promise { - if (!oldUri.path.includes(".")) { + if (!oldUri.path.split("/").pop().includes(".")) { throw vscode.FileSystemError.NoPermissions("Cannot rename a package/folder"); } if (oldUri.path.split(".").pop().toLowerCase() != newUri.path.split(".").pop().toLowerCase()) { @@ -682,9 +714,7 @@ export class FileSystemProvider implements vscode.FileSystemProvider { } } // Get the name of the new file - const newParams = new URLSearchParams(newUri.query); - const newCsp = newParams.has("csp") && ["", "1"].includes(newParams.get("csp")); - const newFileName = newCsp ? newUri.path : newUri.path.slice(1).replace(/\//g, "."); + const newFileName = isfsDocumentName(newUri); // Generate content for the new file const newContent = generateFileContent(newUri, newFileName, await vscode.workspace.fs.readFile(oldUri)); if (newFileStat) { @@ -713,9 +743,10 @@ export class FileSystemProvider implements vscode.FileSystemProvider { // We created a file fireOtherStudioAction(OtherStudioAction.CreatedNewDocument, newUri, response.result.ext[0]); fireOtherStudioAction(OtherStudioAction.FirstTimeDocumentSave, newUri, response.result.ext[1]); - if (newParams.has("project") && newParams.get("project").length) { + const { project } = isfsConfig(newUri); + if (project) { // Add the new document to the project if required - await modifyProject(newUri, "add"); + await addIsfsFileToProject(project, newFileName, api); } } // Sanity check that we find it there, then make client side update things @@ -741,7 +772,7 @@ export class FileSystemProvider implements vscode.FileSystemProvider { if (entry instanceof Directory) { // Get the list of files to compile let compileListPromise: Promise; - if (new URLSearchParams(uri.query).get("project")?.length) { + if (isfsConfig(uri).project) { compileListPromise = projectContentsFromUri(uri, true); } else { compileListPromise = studioOpenDialogFromURI(uri, { flat: true }).then((data) => data.result.content); @@ -749,7 +780,7 @@ export class FileSystemProvider implements vscode.FileSystemProvider { compileList.push(...(await compileListPromise.then((data) => data.map((e) => e.Name)))); } else { // Compile this file - compileList.push(isCSPFile(uri) ? uri.path : uri.path.slice(1).replace(/\//g, ".")); + compileList.push(isCSP(uri) ? uri.path : uri.path.slice(1).replace(/\//g, ".")); } } catch (error) { handleError(error, "Error determining documents to compile."); @@ -834,12 +865,9 @@ export class FileSystemProvider implements vscode.FileSystemProvider { private async _lookup(uri: vscode.Uri, fillInPath?: boolean): Promise { const api = new AtelierAPI(uri); if (uri.path === "/") { - await api - .serverInfo() - .then() - .catch((error) => { - throw vscode.FileSystemError.Unavailable(stringifyError(error) || uri); - }); + await api.serverInfo().catch((error) => { + throw vscode.FileSystemError.Unavailable(stringifyError(error) || uri); + }); } const config = api.config; const rootName = `${config.username}@${config.host}:${config.port}${config.pathPrefix}/${config.ns.toUpperCase()}`; @@ -906,28 +934,13 @@ export class FileSystemProvider implements vscode.FileSystemProvider { if (uri.path.startsWith("/.")) { throw vscode.FileSystemError.NoPermissions("dot-folders not supported by server"); } - const csp = isCSPFile(uri); + const csp = isCSP(uri); const name = path.basename(uri.path); - const fileName = csp ? uri.path : uri.path.slice(1).replace(/\//g, "."); + const fileName = isfsDocumentName(uri, csp); const api = new AtelierAPI(uri); return api .getDoc(fileName, undefined, cachedFile?.mtime) .then((data) => data.result) - .then((result) => { - const fileSplit = fileName.split("."); - const fileType = fileSplit[fileSplit.length - 1]; - if (!csp && ["bpl", "dtl"].includes(fileType)) { - const partialUri = Array.isArray(result.content) ? result.content[0] : String(result.content).split("\n")[0]; - const strippedUri = partialUri.split("&STUDIO=")[0]; - const { https, host, port, pathPrefix } = api.config; - result.content = [ - `${https ? "https" : "http"}://${host}:${port}${pathPrefix}${strippedUri}`, - "Use the link above to launch the external editor in your web browser.", - "Do not edit this document here. It cannot be saved to the server.", - ]; - } - return result; - }) .then( ({ ts, content }) => new File( @@ -953,8 +966,7 @@ export class FileSystemProvider implements vscode.FileSystemProvider { private async _lookupParentDirectory(uri: vscode.Uri): Promise { uri = redirectDotvscodeRoot(uri); - const dirname = uri.with({ path: path.posix.dirname(uri.path) }); - return await this._lookupAsDirectory(dirname); + return this._lookupAsDirectory(uri.with({ path: path.posix.dirname(uri.path) })); } private _fireSoon(...events: vscode.FileChangeEvent[]): void { diff --git a/src/providers/FileSystemProvider/TextSearchProvider.ts b/src/providers/FileSystemProvider/TextSearchProvider.ts index f7e6fdec..78da075e 100644 --- a/src/providers/FileSystemProvider/TextSearchProvider.ts +++ b/src/providers/FileSystemProvider/TextSearchProvider.ts @@ -4,8 +4,7 @@ import { AsyncSearchRequest, SearchResult, SearchMatch } from "../../api/atelier import { AtelierAPI } from "../../api"; import { DocumentContentProvider } from "../DocumentContentProvider"; import { handleError, notNull, outputChannel, RateLimiter } from "../../utils"; -import { config } from "../../extension"; -import { fileSpecFromURI } from "../../utils/FileProviderUtil"; +import { fileSpecFromURI, isfsConfig, IsfsUriParam } from "../../utils/FileProviderUtil"; /** * Convert an `attrline` in a description to a line number in document `content`. @@ -30,7 +29,7 @@ function searchMatchToLine( content: string[], match: SearchMatch, fileName: string, - apiConfigName: string + multilineMethodArgs: boolean ): number | null { let line = match.line ? Number(match.line) : null; if (match.member !== undefined) { @@ -55,6 +54,15 @@ function searchMatchToLine( } } } + } else if (match.attr == "Content" && /^T\d+$/.test(match.member)) { + // This is inside a non-description comment + for (let i = 0; i < content.length; i++) { + if (content[i].trimStart() == match.text) { + // match.text will never have leading whitespace, even if the source line does + line = i; + break; + } + } } else { const memberMatchPattern = new RegExp( `^((?:Class|Client)?Method|Property|XData|Query|Trigger|Parameter|Relationship|Index|ForeignKey|Storage|Projection) ${match.member}` @@ -62,7 +70,7 @@ function searchMatchToLine( for (let i = 0; i < content.length; i++) { if (content[i].match(memberMatchPattern)) { let memend = i + 1; - if (config("multilineMethodArgs", apiConfigName) && content[i].match(/^(?:Class|Client)?Method|Query /)) { + if (multilineMethodArgs && content[i].match(/^(?:Class|Client)?Method|Query /)) { // The class member definition is on multiple lines so update the end for (let j = i + 1; j < content.length; j++) { if (content[j].trim() === "{") { @@ -95,7 +103,12 @@ function searchMatchToLine( // This is in the class member definition // Need to loop due to the possibility of keywords with multiline values for (let j = i; j < content.length; j++) { - if (content[j].includes(match.attr)) { + if ( + content[j].includes( + // If attr is Type or ReturnType, need to search for text + ["Type", "ReturnType"].includes(match.attr) ? match.text : match.attr + ) + ) { line = j; break; } else if ( @@ -139,9 +152,12 @@ function searchMatchToLine( break; } } + } else if (match.attr == "Copyright") { + // This is in the Copyright (multi-line comment at top of class) + line = (match.attrline ?? 1) - 1; } else { // This is in the class definition - const classMatchPattern = new RegExp(`^Class ${fileName.slice(0, fileName.lastIndexOf("."))}`); + const classMatchPattern = new RegExp(`^Class ${fileName.slice(0, -4)}`); let keywordSearch = false; for (let i = 0; i < content.length; i++) { if (content[i].match(classMatchPattern)) { @@ -176,6 +192,9 @@ function searchMatchToLine( } } } + } else if (line == null && match.text == fileName) { + // This is a match in the routine header + line = 0; } return typeof line === "number" ? (fileName.includes("/") ? line - 1 : line) : null; } @@ -264,13 +283,6 @@ function removeConfigExcludes(folder: vscode.Uri, excludes: string[]): string[] } export class TextSearchProvider implements vscode.TextSearchProvider { - /** - * Provide results that match the given text pattern. - * @param query The parameters for this query. - * @param options A set of options to consider while searching. - * @param progress A progress callback that must be invoked for all results. - * @param token A cancellation token. - */ public async provideTextSearchResults( query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, @@ -278,11 +290,8 @@ export class TextSearchProvider implements vscode.TextSearchProvider { token: vscode.CancellationToken ): Promise { const api = new AtelierAPI(options.folder); - const params = new URLSearchParams(options.folder.query); - const decoder = new TextDecoder(); const rateLimiter = new RateLimiter(50); - let counter = 0; - if (!api.enabled) { + if (!api.active) { return { message: { text: "An active server connection is required for searching `isfs` folders.", @@ -290,121 +299,163 @@ export class TextSearchProvider implements vscode.TextSearchProvider { }, }; } - if (token.isCancellationRequested) { - return; - } - - /** Report matches in `file` to the user */ - const reportMatchesForFile = async (file: SearchResult): Promise => { - // The last three checks are needed to protect against - // bad output from the server due to a bug. - if ( - // The user cancelled the search - token.isCancellationRequested || - // The server reported no matches in this file - !file.matches.length || - // The file name is malformed - (file.doc.includes("/") && !/^\/(?:[^/]+\/)+[^/.]*(?:\.[^/.]+)+$/.test(file.doc)) || - (!file.doc.includes("/") && - !/^(%?[\p{L}\d\u{100}-\u{ffff}]+(?:\.[\p{L}\d\u{100}-\u{ffff}]+)+)$/u.test(file.doc)) - ) { - return; - } - - const uri = DocumentContentProvider.getUri(file.doc, "", "", true, options.folder); - const content = decoder.decode(await vscode.workspace.fs.readFile(uri)).split("\n"); - const contentLength = content.length; - // Find all lines that we have matches on - const lines = file.matches - .map((match: SearchMatch) => - token.isCancellationRequested ? null : searchMatchToLine(content, match, file.doc, api.configName) - ) - .filter(notNull); - // Remove duplicates and make them quickly searchable - const matchedLines = new Set(lines); - // Compute all matches for each one - matchedLines.forEach((line) => { - if (token.isCancellationRequested) { + if (token.isCancellationRequested) return; + let counter = 0; + const { csp, project, filter, system, generated, mapped } = isfsConfig(options.folder); + const decoder = new TextDecoder(), + /** Returns a new array with unneeded duplicate glob patters from the given glob pattern array removed */ + deduplicateGlobArray = (globs: string[]): string[] => { + return globs.filter((g) => { + // Need custom parsing so we don't split on forward slash within braces + const parts: string[] = []; + let part = "", + braceLevel = 0; + for (const c of g) { + if (c == "/" && braceLevel == 0) { + parts.push(part); + part = ""; + continue; + } else if (c == "{") { + braceLevel++; + } else if (c == "}") { + braceLevel--; + if (braceLevel < 0) break; // Glob pattern is malformed + } + part += c; + } + if (braceLevel != 0) return true; // Glob pattern is malformed + if (part.length) parts.push(part); + return !( + // For folders that cannot contain web application files, + // globstar after a dot part will never match anything + // because dots are only for file extensions. + ( + (!project && + !csp && + parts.length > 1 && + parts[parts.length - 1] == "**" && + parts[parts.length - 2].includes(".")) || + // A non-dotted last path segment needs the trailing globstar to match properly + (parts.length && parts[parts.length - 1] != "**" && !parts[parts.length - 1].includes(".")) + ) + ); + }); + }, + /** Report matches in `file` to the user */ + reportMatchesForFile = async (file: SearchResult): Promise => { + // The last three checks are needed to protect against + // bad output from the server due to a bug. + if ( + // The user cancelled the search + token.isCancellationRequested || + // The server reported no matches in this file + !file.matches.length || + // The file name is malformed + (file.doc.includes("/") && !/^\/(?:[^/]+\/)+[^/.]*(?:\.[^/.]+)+$/.test(file.doc)) || + (!file.doc.includes("/") && + !/^(%?[\p{L}\d\u{100}-\u{ffff}]+(?:\.[\p{L}\d\u{100}-\u{ffff}]+)+)$/u.test(file.doc)) + ) { return; } - const text = content[line]; - const regex = new RegExp( - query.isRegExp ? query.pattern : query.pattern.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"), - query.isCaseSensitive ? "g" : "gi" - ); - let regexMatch: RegExpExecArray; - const matchRanges: vscode.Range[] = []; - const previewRanges: vscode.Range[] = []; - while ((regexMatch = regex.exec(text)) !== null && counter < options.maxResults) { - const start = regexMatch.index; - const end = start + regexMatch[0].length; - matchRanges.push(new vscode.Range(line, start, line, end)); - previewRanges.push(new vscode.Range(0, start, 0, end)); - counter++; - } - if (matchRanges.length && previewRanges.length) { - if (options.beforeContext) { - // Add preceding context lines that aren't themselves result lines - const previewFrom = Math.max(line - options.beforeContext, 0); - for (let i = previewFrom; i < line; i++) { - if (!matchedLines.has(i)) { - progress.report({ - uri, - text: content[i], - lineNumber: i + 1, - }); + + const uri = DocumentContentProvider.getUri(file.doc, "", "", true, options.folder); + const content = decoder.decode(await vscode.workspace.fs.readFile(uri)).split(/\r?\n/); + const contentLength = content.length; + // Find all lines that we have matches on + const multilineMethodArgs: boolean = vscode.workspace + .getConfiguration("objectscript", options.folder) + .get("multilineMethodArgs"); + const lines = file.matches + .map((match: SearchMatch) => + token.isCancellationRequested ? null : searchMatchToLine(content, match, file.doc, multilineMethodArgs) + ) + .filter(notNull); + // Remove duplicates and make them quickly searchable + const matchedLines = new Set(lines); + // Compute all matches for each one + matchedLines.forEach((line) => { + if (token.isCancellationRequested) { + return; + } + const text = content[line]; + const regex = new RegExp( + query.isRegExp ? query.pattern : query.pattern.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"), + query.isCaseSensitive ? "g" : "gi" + ); + let regexMatch: RegExpExecArray; + const matchRanges: vscode.Range[] = []; + const previewRanges: vscode.Range[] = []; + while ((regexMatch = regex.exec(text)) !== null && counter < options.maxResults) { + const start = regexMatch.index; + const end = start + regexMatch[0].length; + matchRanges.push(new vscode.Range(line, start, line, end)); + previewRanges.push(new vscode.Range(0, start, 0, end)); + counter++; + } + if (matchRanges.length && previewRanges.length) { + if (options.beforeContext) { + // Add preceding context lines that aren't themselves result lines + const previewFrom = Math.max(line - options.beforeContext, 0); + for (let i = previewFrom; i < line; i++) { + if (!matchedLines.has(i)) { + progress.report({ + uri, + text: content[i], + lineNumber: i + 1, + }); + } } } - } - progress.report({ - uri, - ranges: matchRanges, - preview: { - text, - matches: previewRanges, - }, - }); - if (options.afterContext) { - // Add following context lines that aren't themselves result lines - const previewTo = Math.min(line + options.afterContext, contentLength - 1); - for (let i = line + 1; i <= previewTo; i++) { - if (!matchedLines.has(i)) { - progress.report({ - uri, - text: content[i], - lineNumber: i + 1, - }); + progress.report({ + uri, + ranges: matchRanges, + preview: { + text, + matches: previewRanges, + }, + }); + if (options.afterContext) { + // Add following context lines that aren't themselves result lines + const previewTo = Math.min(line + options.afterContext, contentLength - 1); + for (let i = line + 1; i <= previewTo; i++) { + if (!matchedLines.has(i)) { + progress.report({ + uri, + text: content[i], + lineNumber: i + 1, + }); + } } } } - } - }); - }; + }); + }; - // Generate the query pattern that gets sent to the server - // Needed because the server matches the full line against the regex and ignores the case parameter when in regex mode + // Modify the query pattern if we're doing a regex search. + // Needed because the server matches the full line against the + // regex and ignores the case parameter when in regex mode. const pattern = query.isRegExp ? `${!query.isCaseSensitive ? "(?i)" : ""}.*${query.pattern}.*` : query.pattern; + // Remove unneeded duplicate glob patterns from both glob arrays. + // Also attempt to remove any exclude glob patterns that the + // user didn't manually enter because these will almost never + // match any files in our workspace folders. + options.includes = deduplicateGlobArray(options.includes); + options.excludes = deduplicateGlobArray(removeConfigExcludes(options.folder, options.excludes)); + if (api.config.apiVersion >= 6) { // Build the request object - const project = params.has("project") && params.get("project").length ? params.get("project") : undefined; - const system = - (params.has("system") && params.get("system").length ? params.get("system") == "1" : false) || - api.ns === "%SYS"; - const generated = - params.has("generated") && params.get("generated").length ? params.get("generated") == "1" : false; - const mapped = params.has("mapped") && params.get("mapped").length ? params.get("mapped") == "0" : true; const request: AsyncSearchRequest = { request: "search", console: false, // Passed so the server doesn't send us back console output query: pattern, regex: query.isRegExp, - project, + project: project ? project : undefined, // Needs to be undefined if project is an empty string word: query.isWordMatch, // Ignored if regex is true case: query.isCaseSensitive, // Ignored if regex is true wild: false, // Ignored if regex is true documents: project ? undefined : fileSpecFromURI(options.folder), - system, // Ignored if project is defined + system: system || api.ns == "%SYS", // Ignored if project is defined generated, // Ignored if project is defined mapped, // Ignored if project is defined // If options.maxResults is null the search is supposed to return an unlimited number of results @@ -412,25 +463,23 @@ export class TextSearchProvider implements vscode.TextSearchProvider { max: options.maxResults ?? 100000, }; - // Generate the include and exclude filters. + // Generate the include and exclude filter regexes. // The matching is case sensitive and file names are normalized so that the first character // and path separator are '/' (for example, '/%Api/Atelier/v6.cls' and '/csp/user/menu.csp'). - let includesArr = options.includes; - let excludesArr = removeConfigExcludes(options.folder, options.excludes); if (!["", "/"].includes(options.folder.path)) { // Prepend path with a trailing slash const prefix = !options.folder.path.endsWith("/") ? `${options.folder.path}/` : options.folder.path; - includesArr = includesArr.map((e) => `${prefix}${e}`); - excludesArr = excludesArr.map((e) => `${prefix}${e}`); + options.includes = options.includes.map((e) => `${prefix}${e}`); + options.excludes = options.excludes.map((e) => `${prefix}${e}`); } // Add leading slash if we don't start with **/ - includesArr = includesArr.map((e) => (!e.startsWith("**/") ? `/${e}` : e)); - excludesArr = excludesArr.map((e) => (!e.startsWith("**/") ? `/${e}` : e)); + options.includes = options.includes.map((e) => (!e.startsWith("**/") ? `/${e}` : e)); + options.excludes = options.excludes.map((e) => (!e.startsWith("**/") ? `/${e}` : e)); // Convert the array of glob patterns into a single regular expression - if (includesArr.length) { - request.include = includesArr + if (options.includes.length) { + request.include = options.includes .map((e) => { const re = makeRe(e); if (re == false) return null; @@ -439,8 +488,8 @@ export class TextSearchProvider implements vscode.TextSearchProvider { .filter(notNull) .join("|"); } - if (excludesArr.length) { - request.exclude = excludesArr + if (options.excludes.length) { + request.exclude = options.excludes .map((e) => { const re = makeRe(e); if (re == false) return null; @@ -490,12 +539,9 @@ export class TextSearchProvider implements vscode.TextSearchProvider { }) .catch(handleSearchError); } else { - let project: string; let projectList: string[]; let searchPromise: Promise; - const csp = params.has("csp") && ["", "1"].includes(params.get("csp")); - if (params.has("project") && params.get("project").length) { - project = params.get("project"); + if (project) { projectList = await api .actionQuery( "SELECT CASE WHEN Type = 'PKG' THEN Name||'.*.cls' WHEN Type = 'CLS' THEN Name||'.cls' ELSE Name END Name " + @@ -544,13 +590,9 @@ export class TextSearchProvider implements vscode.TextSearchProvider { ) ).then((results) => results.map((result) => (result.status == "fulfilled" ? result.value : [])).flat()); } else { - const sysStr = params.has("system") && params.get("system").length ? params.get("system") : "0"; - const genStr = params.has("generated") && params.get("generated").length ? params.get("generated") : "0"; - - let uri = options.folder; - - if (!params.get("filter")) { - // Unless isfs spec already includes a non-empty filter (which it rarely does), apply includes and excludes at the server side. + let uriForSpec = options.folder; + if (!filter) { + // Unless isfs spec already includes a non-empty filter, apply includes and excludes at the server side. // Convert **/ separators and /** suffix into multiple *-patterns that simulate these elements of glob syntax. // Function to convert glob-style filters into ones that the server understands @@ -578,11 +620,9 @@ export class TextSearchProvider implements vscode.TextSearchProvider { }; // Invoke our recursive function - filters - .filter((value) => csp || !value.match(/\.([a-z]+|\*)\/\*\*$/)) // drop superfluous entries ending .xyz/** or .*/** when not handling CSP files - .forEach((value) => { - recurse(value); - }); + filters.forEach((value) => { + recurse(value); + }); // Convert map to array and return it const results: string[] = []; @@ -592,18 +632,19 @@ export class TextSearchProvider implements vscode.TextSearchProvider { return results; }; - const filterExclude = convertFilters(removeConfigExcludes(options.folder, options.excludes)).join(",'"); + const filterExclude = convertFilters(options.excludes).join(",'"); const filterInclude = options.includes.length > 0 ? convertFilters(options.includes).join(",") : filterExclude - ? fileSpecFromURI(uri) // Excludes were specified but no includes, so start with the default includes (this step makes type=cls|rtn effective) + ? fileSpecFromURI(uriForSpec) // Excludes were specified but no includes, so start with the default includes (this step makes type=cls|rtn effective) : ""; - const filter = filterInclude + (!filterExclude ? "" : ",'" + filterExclude); - if (filter) { + const newFilter = filterInclude + (!filterExclude ? "" : ",'" + filterExclude); + if (newFilter) { // Unless isfs is serving CSP files, slash separators in filters must be converted to dot ones before sending to server - params.append("filter", csp ? filter : filter.replace(/\//g, ".")); - uri = options.folder.with({ query: params.toString() }); + const params = new URLSearchParams(uriForSpec.query); + params.set(IsfsUriParam.Filter, csp ? newFilter : newFilter.replace(/\//g, ".")); + uriForSpec = uriForSpec.with({ query: params.toString() }); } } @@ -613,9 +654,9 @@ export class TextSearchProvider implements vscode.TextSearchProvider { regex: query.isRegExp, word: query.isWordMatch, case: query.isCaseSensitive, - files: fileSpecFromURI(uri), - sys: sysStr === "1" || (sysStr === "0" && api.ns === "%SYS"), - gen: genStr === "1", + files: fileSpecFromURI(uriForSpec), + sys: system || api.ns == "%SYS", + gen: generated, // If options.maxResults is null the search is supposed to return an unlimited number of results // Since there's no way for us to pass "unlimited" to the server, I chose a very large number max: options.maxResults ?? 100000, diff --git a/src/providers/WorkspaceSymbolProvider.ts b/src/providers/WorkspaceSymbolProvider.ts index ee94046c..381b811a 100644 --- a/src/providers/WorkspaceSymbolProvider.ts +++ b/src/providers/WorkspaceSymbolProvider.ts @@ -2,9 +2,9 @@ import * as vscode from "vscode"; import { AtelierAPI } from "../api"; import { DocumentContentProvider } from "./DocumentContentProvider"; import { filesystemSchemas } from "../extension"; -import { fileSpecFromURI } from "../utils/FileProviderUtil"; +import { fileSpecFromURI, isfsConfig } from "../utils/FileProviderUtil"; import { allDocumentsInWorkspace } from "../utils/documentIndex"; -import { handleError } from "../utils"; +import { handleError, queryToFuzzyLike } from "../utils"; export class WorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider { private readonly _sqlPrefix: string = @@ -100,37 +100,31 @@ export class WorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider { ): Promise { if (!vscode.workspace.workspaceFolders?.length) return; // Convert query to a LIKE compatible pattern - let pattern = "%"; - for (const c of query.toLowerCase()) pattern += `${["_", "%", "\\"].includes(c) ? "\\" : ""}${c}%`; + const pattern = queryToFuzzyLike(query); if (token.isCancellationRequested) return; // Get results for all workspace folders return Promise.allSettled( vscode.workspace.workspaceFolders.map((wsFolder) => { if (filesystemSchemas.includes(wsFolder.uri.scheme)) { - const params = new URLSearchParams(wsFolder.uri.query); - if (params.has("csp") && ["", "1"].includes(params.get("csp"))) { + const { csp, system, generated, mapped, project } = isfsConfig(wsFolder.uri); + if (csp) { // No classes or class members in web application folders return Promise.resolve([]); } else { const api = new AtelierAPI(wsFolder.uri); if (!api.active || token.isCancellationRequested) return Promise.resolve([]); - const project = params.get("project") ?? ""; return api .actionQuery(`${this._sqlPrefix}${project.length ? this._sqlPrj : this._sqlDocs}${this._sqlSuffix}`, [ project.length ? project : fileSpecFromURI(wsFolder.uri), - params.has("system") && params.get("system").length - ? params.get("system") - : api.ns == "%SYS" - ? "1" - : "0", - params.has("generated") && params.get("generated").length ? params.get("generated") : "0", - params.has("mapped") && params.get("mapped") == "0" ? "0" : "1", + system || api.ns == "%SYS" ? "1" : "0", + generated ? "1" : "0", + mapped ? "1" : "0", pattern, ]) .then((data) => (token.isCancellationRequested ? [] : this._queryResultToSymbols(data, wsFolder))); } } else { - // Use the document index to determien the classes to search + // Use the document index to determine the classes to search const api = new AtelierAPI(wsFolder.uri); if (!api.active) return Promise.resolve([]); const docs = allDocumentsInWorkspace(wsFolder).filter((d) => d.endsWith(".cls")); diff --git a/src/utils/FileProviderUtil.ts b/src/utils/FileProviderUtil.ts index eb86241a..099a0eb8 100644 --- a/src/utils/FileProviderUtil.ts +++ b/src/utils/FileProviderUtil.ts @@ -2,13 +2,47 @@ import * as vscode from "vscode"; import { AtelierAPI } from "../api"; import { ProjectItem } from "../commands/project"; -export async function projectContentsFromUri(uri: vscode.Uri, overrideFlat?: boolean): Promise { +/** `isfs(-readonly)` query parameters that configure the documents shown */ +export enum IsfsUriParam { + Project = "project", + System = "system", + Generated = "generated", + Mapped = "mapped", + Filter = "filter", + CSP = "csp", + NS = "ns", +} + +interface IsfsUriConfig { + system: boolean; + generated: boolean; + mapped: boolean; + filter: string; + project: string; + csp: boolean; + ns?: string; +} + +/** Return the values of all configuration query parameters for `uri` */ +export function isfsConfig(uri: vscode.Uri): IsfsUriConfig { + const params = new URLSearchParams(uri.query); + return { + system: params.get(IsfsUriParam.System) == "1", + generated: params.get(IsfsUriParam.Generated) == "1", + mapped: params.get(IsfsUriParam.Mapped) != "0", + filter: params.get(IsfsUriParam.Filter) ?? "", + project: params.get(IsfsUriParam.Project) ?? "", + csp: ["", "1"].includes(params.get(IsfsUriParam.CSP)), + ns: params.get(IsfsUriParam.NS) || undefined, + }; +} + +export async function projectContentsFromUri(uri: vscode.Uri, flat = false): Promise { const api = new AtelierAPI(uri); if (!api.active) { return; } - const params = new URLSearchParams(uri.query); - const flat = overrideFlat ?? false; + const { project } = isfsConfig(uri); let folder = !uri.path.endsWith("/") ? uri.path + "/" : uri.path; folder = folder.startsWith("/") ? folder.slice(1) : folder; if (folder == "/") { @@ -16,7 +50,6 @@ export async function projectContentsFromUri(uri: vscode.Uri, overrideFlat?: boo folder = ""; } const folderDots = folder.replace(/\//g, "."); - const project = params.get("project"); let query: string; let parameters: string[]; if (flat) { @@ -36,7 +69,7 @@ export async function projectContentsFromUri(uri: vscode.Uri, overrideFlat?: boo "pil.Type = 'DIR' AND SUBSTR(sod.Name,2) %STARTSWITH ? AND SUBSTR(sod.Name,2) %STARTSWITH pil.Name||'/'"; parameters = [project, folderDots, folder, `Name %STARTSWITH '/${folder}'`, project, folder]; } else { - if (folder.length) { + if (folder) { const l = String(folder.length + 1); // Need the + 1 because SUBSTR is 1 indexed query = "SELECT sod.Name, pil.Type FROM %Library.RoutineMgr_StudioOpenDialog(?,1,1,1,0,0,1) AS sod JOIN %Studio.Project_ProjectItemsList(?) AS pil ON " + @@ -106,8 +139,7 @@ export async function projectContentsFromUri(uri: vscode.Uri, overrideFlat?: boo } export function fileSpecFromURI(uri: vscode.Uri): string { - const params = new URLSearchParams(uri.query); - const csp = params.has("csp") && ["", "1"].includes(params.get("csp")); + const { csp, filter } = isfsConfig(uri); const folder = !csp ? uri.path.replace(/\/$/, "").replace(/\//g, ".") @@ -116,19 +148,13 @@ export function fileSpecFromURI(uri: vscode.Uri): string { : uri.path.endsWith("/") ? uri.path : uri.path + "/"; - // The query filter represents the studio spec to be used, - // overrides.filter represents the SQL query that will be passed to the server + // The filter Uri parameter is the first argument to StudioOpenDialog (Spec) let specOpts = ""; - // If filter is specified on the URI, use it - if (params.has("filter") && params.get("filter").length) { - specOpts = params.get("filter"); - if (!csp) { - // always exclude Studio projects, since we can't do anything with them - specOpts += ",'*.prj"; - } - } // otherwise, reference the type to get the desired files. - else if (csp) { + if (filter) { + // Always exclude Studio projects, BPL, and DTL since we can't do anything with them + specOpts = filter + ",'*.prj,'*.bpl,'*.dtl"; + } else if (csp) { specOpts = folder.length > 1 ? "*" : "*.cspall"; } else { specOpts = "*.cls,*.inc,*.mac,*.int"; @@ -141,19 +167,18 @@ export function studioOpenDialogFromURI( overrides: { flat?: boolean; filter?: string } = { flat: false, filter: "" } ): Promise { const api = new AtelierAPI(uri); - if (!api.active) { - return; - } - const sql = `SELECT Name, Type FROM %Library.RoutineMgr_StudioOpenDialog(?,?,?,?,?,?,?,?,?,?)`; - const params = new URLSearchParams(uri.query); - const spec = fileSpecFromURI(uri); - const notStudio = "0"; - const dir = "1"; - const orderBy = "1"; - const generated = params.has("generated") && params.get("generated").length ? params.get("generated") : "0"; - const system = - params.has("system") && params.get("system").length ? params.get("system") : api.ns === "%SYS" ? "1" : "0"; - const flat = overrides && overrides.flat ? "1" : "0"; - const mapped = params.has("mapped") && params.get("mapped") == "0" ? "0" : "1"; - return api.actionQuery(sql, [spec, dir, orderBy, system, flat, notStudio, generated, overrides.filter, "0", mapped]); + if (!api.active) return; + const { system, generated, mapped } = isfsConfig(uri); + return api.actionQuery("SELECT Name, Type FROM %Library.RoutineMgr_StudioOpenDialog(?,?,?,?,?,?,?,?,?,?)", [ + fileSpecFromURI(uri), + "1", // Dir (1 means ascending order) + "1", // OrderBy (1 means name, case insensitive) + system || api.ns == "%SYS" ? "1" : "0", + overrides?.flat ? "1" : "0", + "0", // NotStudio (0 means hide globals and OBJ files) + generated ? "1" : "0", + overrides.filter, + "0", // RoundTime (0 means no rounding) + mapped ? "1" : "0", + ]); } diff --git a/src/utils/index.ts b/src/utils/index.ts index e4c89c27..354152ff 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -14,7 +14,7 @@ import { filesystemSchemas, } from "../extension"; import { getCategory } from "../commands/export"; -import { isCSPFile } from "../providers/FileSystemProvider/FileSystemProvider"; +import { isCSP, isfsDocumentName } from "../providers/FileSystemProvider/FileSystemProvider"; import { AtelierAPI } from "../api"; export const outputChannel = vscode.window.createOutputChannel("ObjectScript", "vscode-objectscript-output"); @@ -242,18 +242,7 @@ export function currentFileFromContent(uri: vscode.Uri, content: string | Buffer [, name, ext = "mac"] = match; } } else { - if (notIsfs(uri)) { - name = getServerDocName(uri); - } else { - name = uri.path; - } - // Need to strip leading / for custom Studio documents which should not be treated as files. - // e.g. For a custom Studio document Test.ZPM, the variable name would be /Test.ZPM which is - // not the document name. The document name is Test.ZPM so requests made to the Atelier APIs - // using the name with the leading / would fail to find the document. - if (name?.charAt(0) == "/") { - name = name.slice(1); - } + name = notIsfs(uri) ? getServerDocName(uri) : isfsDocumentName(uri); } if (!name) { return null; @@ -319,18 +308,7 @@ export function currentFile(document?: vscode.TextDocument): CurrentTextFile { [, name, ext = "mac"] = match; } } else { - if (notIsfs(document.uri)) { - name = getServerDocName(document.uri); - } else { - name = uri.path; - } - // Need to strip leading / for custom Studio documents which should not be treated as files. - // e.g. For a custom Studio document Test.ZPM, the variable name would be /Test.ZPM which is - // not the document name. The document name is Test.ZPM so requests made to the Atelier APIs - // using the name with the leading / would fail to find the document. - if (name?.charAt(0) == "/") { - name = name.slice(1); - } + name = notIsfs(uri) ? getServerDocName(uri) : isfsDocumentName(uri); } if (!name) { return null; @@ -392,29 +370,6 @@ export function connectionTarget(uri?: vscode.Uri): ConnectionTarget { return result; } -/** - * Given a URI, returns a server name for it if it is under isfs[-readonly] or null if it is not an isfs file. - * @param uri URI to evaluate - */ -export function getServerName(uri: vscode.Uri): string { - if (!schemas.includes(uri.scheme)) { - return null; - } - if (isCSPFile(uri)) { - // The full file path is the server name of the file. - return uri.path; - } else { - // Complex case: replace folder slashes with dots. - const filePath = uri.path.slice(1); - let serverName = filePath.replace(/\//g, "."); - if (!filePath.split("/").pop().includes(".")) { - // This is a package so add the .PKG extension - serverName += ".PKG"; - } - return serverName; - } -} - export function currentWorkspaceFolder(document?: vscode.TextDocument): string { document = document ? document : vscode.window.activeTextEditor && vscode.window.activeTextEditor.document; if (document) { @@ -633,7 +588,7 @@ export async function addWsServerRootFolderData(uri: vscode.Uri): Promise const value: WSServerRootFolderData = { redirectDotvscode: true, }; - if (isCSPFile(uri) && !["", "/"].includes(uri.path)) { + if (isCSP(uri) && !["", "/"].includes(uri.path)) { // A CSP-type root folder for a specific webapp that already has a .vscode/settings.json file must not redirect .vscode/* references const api = new AtelierAPI(uri); api @@ -669,7 +624,7 @@ export function redirectDotvscodeRoot(uri: vscode.Uri): vscode.Uri { return uri; } let namespace: string; - const andCSP = !isCSPFile(uri) ? "&csp" : ""; + const andCSP = !isCSP(uri) ? "&csp" : ""; const nsMatch = `&${uri.query}&`.match(/&ns=([^&]+)&/); if (nsMatch) { namespace = nsMatch[1].toUpperCase(); @@ -820,15 +775,13 @@ interface ConnQPItem extends vscode.QuickPickItem { /** * Prompt the user to pick an active server connection that's used in this workspace. * Returns the uri of the workspace folder corresponding to the chosen connection. - * Returns `undefined` if there are no active server connections in this workspace, - * or if the user dismisses the QuickPick. If there is only one active server - * connection, that will be returned without prompting the user. + * If there is only one active server connection, it will be returned without prompting the user. * * @param minVersion Optional minimum server version to enforce, in semantic version form (20XX.Y.Z). * @returns `undefined` if there were no suitable server connections and `null` if the * user explicitly escaped from the QuickPick. */ -export async function getWsServerConnection(minVersion?: string): Promise { +export async function getWsServerConnection(minVersion?: string): Promise { if (!vscode.workspace.workspaceFolders?.length) return; const conns: ConnQPItem[] = []; for (const wsFolder of vscode.workspace.workspaceFolders) { @@ -859,6 +812,13 @@ export async function getWsServerConnection(minVersion?: string): Promise c?.uri ?? null); } +/** Convert `query` to a fuzzy LIKE compatible pattern */ +export function queryToFuzzyLike(query: string): string { + let p = "%"; + for (const c of query.toLowerCase()) p += `${["_", "%", "\\"].includes(c) ? "\\" : ""}${c}%`; + return p; +} + class Semaphore { /** Queue of tasks waiting to acquire the semaphore */ private _tasks: (() => void)[] = []; From e32c983c1f3a25dc10a5f0110b835003fd9a55e9 Mon Sep 17 00:00:00 2001 From: Brett Saviano Date: Thu, 20 Feb 2025 07:41:43 -0500 Subject: [PATCH 2/4] Update src/providers/DocumentContentProvider.ts Co-authored-by: John Murray --- src/providers/DocumentContentProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/DocumentContentProvider.ts b/src/providers/DocumentContentProvider.ts index d65cf2ec..c88a6216 100644 --- a/src/providers/DocumentContentProvider.ts +++ b/src/providers/DocumentContentProvider.ts @@ -245,7 +245,7 @@ export class DocumentContentProvider implements vscode.TextDocumentContentProvid public async provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): Promise { const api = new AtelierAPI(uri); - // Even though this is technically a "objectscript" Uri, the query parameters are the same as "isfs" + // Even though this is technically an "objectscript" Uri, the query parameters are the same as "isfs" const { csp, ns } = isfsConfig(uri); const fileName = csp ? uri.path.slice(1) : uri.path.split("/").slice(1).join("."); if (ns) api.setNamespace(ns); From 30d4a41f2afeeec8ff493bed56d5135767b1026e Mon Sep 17 00:00:00 2001 From: Brett Saviano Date: Thu, 20 Feb 2025 07:41:53 -0500 Subject: [PATCH 3/4] Update src/api/index.ts Co-authored-by: John Murray --- src/api/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api/index.ts b/src/api/index.ts index 3260f0d7..bd667881 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -122,7 +122,9 @@ export class AtelierAPI { namespace = parts[1]; } else { const { ns } = isfsConfig(wsOrFile); - if (ns) namespace = ns; + if (ns) { + namespace = ns; + } } } else { const wsFolderOfFile = vscode.workspace.getWorkspaceFolder(wsOrFile); From 2e8d972f0b747bf32dd21457c677b00da19afbfe Mon Sep 17 00:00:00 2001 From: Brett Saviano Date: Thu, 20 Feb 2025 11:27:16 -0500 Subject: [PATCH 4/4] SQL micro-optimization --- src/commands/unitTest.ts | 2 +- src/providers/WorkspaceSymbolProvider.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/commands/unitTest.ts b/src/commands/unitTest.ts index 4ac4e5f9..e74aa9c2 100644 --- a/src/commands/unitTest.ts +++ b/src/commands/unitTest.ts @@ -111,7 +111,7 @@ async function addTestItemsForClass(testController: vscode.TestController, paren const inheritedMethods: { Name: string; Origin: string }[] = await api .actionQuery( "SELECT Name, Origin FROM %Dictionary.CompiledMethod WHERE " + - "parent->ID = ? AND Origin != parent->ID AND Name %STARTSWITH 'Test' " + + "Parent = ? AND Origin != Parent AND Name %STARTSWITH 'Test' " + "AND ClassMethod = 0 AND ClientMethod = 0 ORDER BY Name", [parentSymbols[0].name] ) diff --git a/src/providers/WorkspaceSymbolProvider.ts b/src/providers/WorkspaceSymbolProvider.ts index 381b811a..b1b991ac 100644 --- a/src/providers/WorkspaceSymbolProvider.ts +++ b/src/providers/WorkspaceSymbolProvider.ts @@ -10,16 +10,16 @@ export class WorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider { private readonly _sqlPrefix: string = "SELECT mem.Name, mem.Parent, mem.Type FROM (" + " SELECT Name, Name AS Parent, 'Class' AS Type FROM %Dictionary.ClassDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'Method' AS Type FROM %Dictionary.MethodDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'Property' AS Type FROM %Dictionary.PropertyDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'Parameter' AS Type FROM %Dictionary.ParameterDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'Index' AS Type FROM %Dictionary.IndexDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'ForeignKey' AS Type FROM %Dictionary.ForeignKeyDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'XData' AS Type FROM %Dictionary.XDataDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'Query' AS Type FROM %Dictionary.QueryDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'Trigger' AS Type FROM %Dictionary.TriggerDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'Storage' AS Type FROM %Dictionary.StorageDefinition" + - " UNION SELECT Name, Parent->ID AS Parent, 'Projection' AS Type FROM %Dictionary.ProjectionDefinition" + + " UNION SELECT Name, Parent, 'Method' AS Type FROM %Dictionary.MethodDefinition" + + " UNION SELECT Name, Parent, 'Property' AS Type FROM %Dictionary.PropertyDefinition" + + " UNION SELECT Name, Parent, 'Parameter' AS Type FROM %Dictionary.ParameterDefinition" + + " UNION SELECT Name, Parent, 'Index' AS Type FROM %Dictionary.IndexDefinition" + + " UNION SELECT Name, Parent, 'ForeignKey' AS Type FROM %Dictionary.ForeignKeyDefinition" + + " UNION SELECT Name, Parent, 'XData' AS Type FROM %Dictionary.XDataDefinition" + + " UNION SELECT Name, Parent, 'Query' AS Type FROM %Dictionary.QueryDefinition" + + " UNION SELECT Name, Parent, 'Trigger' AS Type FROM %Dictionary.TriggerDefinition" + + " UNION SELECT Name, Parent, 'Storage' AS Type FROM %Dictionary.StorageDefinition" + + " UNION SELECT Name, Parent, 'Projection' AS Type FROM %Dictionary.ProjectionDefinition" + ") AS mem "; private readonly _sqlPrj: string =