diff --git a/.vscode/launch.json b/.vscode/launch.json index 6c041caf..e41fac94 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,22 +2,6 @@ { "version": "0.2.0", "configurations": [ - { - "name": "Launch Extension Alone", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "--disable-extensions", - "--extensionDevelopmentPath=${workspaceRoot}" - ], - "stopOnEntry": false, - "sourceMaps": true, - "outFiles": [ - "${workspaceRoot}/dist/**/*.js" - ], - "preLaunchTask": "npm: webpack" - }, { "name": "Launch Extension", "type": "extensionHost", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fa18e40..3cb7efc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,7 @@ The extensions in the [official extension pack](https://docs.intersystems.com/co 1. [Node.js](https://nodejs.org/) 22 1. Windows, macOS, or Linux 1. [Visual Studio Code](https://code.visualstudio.com/) +1. [Prettier - Code formatter](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) (Optional) ### Setup @@ -44,8 +45,6 @@ Then, open the debug panel by clicking the `Run and Debug` icon on the Activity option from the top menu, and click start. A new window will launch with the title `[Extension Development Host]`. Do your testing here. -If you want to disable all other extensions when testing in the Extension Development Host, choose the `Launch Extension Alone` option instead. - ### Pull requests Work should be done on a unique branch -- not the master branch. Pull requests require the approval of two PMC members, as described in the [Governance document](GOVERNANCE.md). PMC review is often high level, so in addition to that, you should request a review by someone familiar with the technical details of your particular pull request. diff --git a/src/commands/compile.ts b/src/commands/compile.ts index 17f9cb4f..1a4281d7 100644 --- a/src/commands/compile.ts +++ b/src/commands/compile.ts @@ -36,7 +36,7 @@ import { } from "../utils"; import { StudioActions } from "./studio"; import { NodeBase, PackageNode, RootNode } from "../explorer/nodes"; -import { getUrisForDocument, updateIndexForDocument } from "../utils/documentIndex"; +import { getUrisForDocument, updateIndex } from "../utils/documentIndex"; async function compileFlags(): Promise { const defaultFlags = config().compileFlags; @@ -245,9 +245,9 @@ export async function loadChanges(files: (CurrentTextFile | CurrentBinaryFile)[] // Re-throw the error throw e; }); - if (isClassOrRtn(file.uri)) { + if (isClassOrRtn(file.uri.path)) { // Update the document index - updateIndexForDocument(file.uri, undefined, undefined, content); + updateIndex(file.uri, content); } } else if (filesystemSchemas.includes(file.uri.scheme)) { fileSystemProvider.fireFileChanged(file.uri); @@ -659,7 +659,7 @@ export async function importLocalFilesToServerSideFolder(wsFolderUri: vscode.Uri return; } // Filter out non-ISC files - uris = uris.filter(isClassOrRtn); + uris = uris.filter((uri) => isClassOrRtn(uri.path)); if (uris.length == 0) { vscode.window.showErrorMessage("No classes or routines were selected.", "Dismiss"); return; diff --git a/src/commands/export.ts b/src/commands/export.ts index 5d28252c..48d09095 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -8,6 +8,7 @@ import { getWsFolder, handleError, isClassOrRtn, + isImportableLocalFile, lastUsedLocalUri, notNull, outputChannel, @@ -20,7 +21,7 @@ import { } from "../utils"; import { pickDocuments } from "../utils/documentPicker"; import { NodeBase } from "../explorer/nodes"; -import { updateIndexForDocument } from "../utils/documentIndex"; +import { updateIndex } from "../utils/documentIndex"; export function getCategory(fileName: string, addCategory: any | boolean): string { const fileExt = fileName.split(".").pop().toLowerCase(); @@ -112,9 +113,9 @@ async function exportFile(wsFolderUri: vscode.Uri, namespace: string, name: stri // Re-throw the error throw e; }); - if (isClassOrRtn(fileUri)) { + if (isClassOrRtn(fileUri.path) || isImportableLocalFile(fileUri)) { // Update the document index - updateIndexForDocument(fileUri, undefined, undefined, content); + updateIndex(fileUri, content); } const ws = workspaceFolderOfUri(fileUri); const mtime = Number(new Date(data.result.ts + "Z")); diff --git a/src/commands/unitTest.ts b/src/commands/unitTest.ts index bebd162b..078be2c0 100644 --- a/src/commands/unitTest.ts +++ b/src/commands/unitTest.ts @@ -8,7 +8,7 @@ import { notIsfs, displayableUri, stripClassMemberNameQuotes, - uriIsParentOf, + uriIsAncestorOf, } from "../utils"; import { fileSpecFromURI, isfsConfig } from "../utils/FileProviderUtil"; import { AtelierAPI } from "../api"; @@ -62,9 +62,8 @@ const textDecoder = new TextDecoder(); /** Find the root `TestItem` for `uri` */ function rootItemForItem(testController: vscode.TestController, uri: vscode.Uri): vscode.TestItem | undefined { let rootItem: vscode.TestItem; - const uriString = uri.toString(); for (const [, i] of testController.items) { - if (uriIsParentOf(i.uri, uri) || uriString == i.uri.toString()) { + if (uriIsAncestorOf(i.uri, uri)) { rootItem = i; break; } @@ -422,10 +421,9 @@ async function runHandler( // Add the initial items to the queue to process const queue: vscode.TestItem[] = []; - const rootUriString = root.uri.toString(); if (request.include?.length) { request.include.forEach((i) => { - if (uriIsParentOf(root.uri, i.uri) || i.uri.toString() == rootUriString) { + if (uriIsAncestorOf(root.uri, i.uri)) { queue.push(i); } }); @@ -1082,7 +1080,7 @@ export function setUpTestController(context: vscode.ExtensionContext): vscode.Di // Update root items if needed e.removed.forEach((wf) => { testController.items.forEach((i) => { - if (uriIsParentOf(wf.uri, i.uri)) { + if (uriIsAncestorOf(wf.uri, i.uri)) { // Remove this TestItem classesForRoot.delete(i); testController.items.delete(i.id); diff --git a/src/extension.ts b/src/extension.ts index fd0198fb..26e39c81 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -95,6 +95,7 @@ import { otherDocExts, getWsServerConnection, isClassOrRtn, + isImportableLocalFile, addWsServerRootFolderData, getWsFolder, exportedUris, @@ -143,7 +144,7 @@ import { indexWorkspaceFolder, removeIndexOfWorkspaceFolder, storeTouchedByVSCode, - updateIndexForDocument, + updateIndex, } from "./utils/documentIndex"; import { WorkspaceNode, NodeBase } from "./explorer/nodes"; import { showPlanWebview } from "./commands/showPlanPanel"; @@ -1090,11 +1091,13 @@ export async function activate(context: vscode.ExtensionContext): Promise { checkChangedOnServer(currentFile(event.document)); } if ( - [clsLangId, macLangId, intLangId, incLangId].includes(event.document.languageId) && - notIsfs(event.document.uri) + notIsfs(event.document.uri) && + ([clsLangId, macLangId, intLangId, incLangId].includes(event.document.languageId) || + isClassOrRtn(event.document.uri.path) || + isImportableLocalFile(event.document.uri)) ) { // Update the local workspace folder index to incorporate this change - updateIndexForDocument(event.document.uri); + updateIndex(event.document.uri); } }), vscode.window.onDidChangeActiveTextEditor(async (editor) => { @@ -1426,7 +1429,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { e.files // Attempt to fill in stub content for classes and routines that // are not server-side files and were not created due to an export - .filter((f) => notIsfs(f) && isClassOrRtn(f) && !exportedUris.has(f.toString())) + .filter((f) => notIsfs(f) && isClassOrRtn(f.path) && !exportedUris.has(f.toString())) .forEach(async (uri) => { // Need to wait in case file was created using "Save As..." // because in that case the file gets created without diff --git a/src/utils/documentIndex.ts b/src/utils/documentIndex.ts index c610cb27..19614fa3 100644 --- a/src/utils/documentIndex.ts +++ b/src/utils/documentIndex.ts @@ -5,7 +5,6 @@ import { RateLimiter, currentFileFromContent, exportedUris, - getServerDocName, isClassOrRtn, isImportableLocalFile, notIsfs, @@ -13,6 +12,7 @@ import { outputChannel, displayableUri, isCompilable, + uriIsAncestorOf, } from "."; import { isText } from "istextorbinary"; import { AtelierAPI } from "../api"; @@ -22,9 +22,9 @@ import { sendClientSideSyncTelemetryEvent } from "../extension"; interface WSFolderIndex { /** The `FileSystemWatcher` for this workspace folder */ watcher: vscode.FileSystemWatcher; - /** Map of InterSystems classes and routines in this workspace to their `Uri`s */ + /** Map of document names (i.e., server-side names) to VSCode URIs */ documents: Map; - /** Map of stringified `Uri`s to their InterSystems class/routine name */ + /** Map of VSCode URIs to document names */ uris: Map; } @@ -179,8 +179,8 @@ function outputDelete(docName: string, status: string, ts: string): number { /** Create index of `wsFolder` and set up a `FileSystemWatcher` to keep the index up to date */ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Promise { if (!notIsfs(wsFolder.uri)) return; - const documents: Map = new Map(); - const uris: Map = new Map(); + const documents: WSFolderIndex["documents"] = new Map(); + const uris: WSFolderIndex["uris"] = new Map(); // Limit the initial indexing to 250 files at once to avoid EMFILE errors const fsRateLimiter = new RateLimiter(250); // Limit FileSystemWatcher events that may produce a putDoc() @@ -189,26 +189,32 @@ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Pr // A cache of the last time each file was last changed const lastChangeMtimes: Map = new Map(); // Index classes and routines that currently exist - vscode.workspace - .findFiles(new vscode.RelativePattern(wsFolder, "{**/*.cls,**/*.mac,**/*.int,**/*.inc}")) - .then((files) => files.forEach((file) => fsRateLimiter.call(() => updateIndexForDocument(file, documents, uris)))); + vscode.workspace.findFiles(new vscode.RelativePattern(wsFolder, "{**/*}")).then((files) => + files.forEach((file) => + fsRateLimiter.call(() => { + if (isClassOrRtn(file.path) || isImportableLocalFile(file)) { + return updateIndexInternal(file, documents, uris, true); + } + }) + ) + ); // Watch for all file changes const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(wsFolder, "**/*")); const debouncedCompile = generateCompileFn(); const debouncedDelete = generateDeleteFn(wsFolder.uri); - const updateIndexAndSyncChanges = async (uri: vscode.Uri, created = false): Promise => { - if (uri.scheme != wsFolder.uri.scheme) { - // We don't care about virtual files that might be - // part of the workspace folder, like "git" files - return; - } - if (vscode.workspace.getWorkspaceFolder(uri)?.uri.toString() != wsFolder.uri.toString()) { - // This file is not in this workspace folder. This can occur if there - // are two workspace folders open where one is a subfolder of the other - // and the file being changed is in the subfolder. This event will fire - // for both watchers, but VS Code will correctly report that the file - // is in the subfolder workspace folder, so the parent watcher can - // safely ignore the event. + const notToSync = (uri: vscode.Uri) => + // We don't care about virtual files that might be + // part of the workspace folder, like "git" files + uri.scheme != wsFolder.uri.scheme || + // This file is not in this workspace folder. This can occur if there + // are two workspace folders open where one is a subfolder of the other + // and the file being changed is in the subfolder. This event will fire + // for both watchers, but VS Code will correctly report that the file + // is in the subfolder workspace folder, so the parent watcher can + // safely ignore the event. + vscode.workspace.getWorkspaceFolder(uri)?.uri.toString() != wsFolder.uri.toString(); + async function updateIndexAndSyncChange(uri: vscode.Uri, created = false): Promise { + if (notToSync(uri)) { return; } if (!uri.path.split("/").pop().includes(".")) { @@ -252,15 +258,7 @@ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Pr const vscodeChange = touchedByVSCode.has(uriString); const sync = api.active && (syncLocalChanges == "all" || (syncLocalChanges == "vscodeOnly" && vscodeChange)); touchedByVSCode.delete(uriString); - let change: WSFolderIndexChange = {}; - if (isClassOrRtn(uri)) { - change = await updateIndexForDocument(uri, documents, uris); - } else if (sync && isImportableLocalFile(uri)) { - change.addedOrChanged = await getCurrentFile(uri); - if (change.addedOrChanged?.fileName) { - sendClientSideSyncTelemetryEvent(change.addedOrChanged.fileName.split(".").pop().toLowerCase()); - } - } + const change = await updateIndexInternal(uri, documents, uris, sync); if (!sync || (!change.addedOrChanged && !change.removed)) return; if (change.addedOrChanged) { // Create or update the document on the server @@ -285,22 +283,9 @@ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Pr // Delete document on the server debouncedDelete(change.removed); } - }; - watcher.onDidChange((uri) => restRateLimiter.call(() => updateIndexAndSyncChanges(uri))); - watcher.onDidCreate((uri) => restRateLimiter.call(() => updateIndexAndSyncChanges(uri, true))); - watcher.onDidDelete((uri) => { - if (uri.scheme != wsFolder.uri.scheme) { - // We don't care about virtual files that might be - // part of the workspace folder, like "git" files - return; - } - if (vscode.workspace.getWorkspaceFolder(uri)?.uri.toString() != wsFolder.uri.toString()) { - // This file is not in this workspace folder. This can occur if there - // are two workspace folders open where one is a subfolder of the other - // and the file being changed is in the subfolder. This event will fire - // for both watchers, but VS Code will correctly report that the file - // is in the subfolder workspace folder, so the parent watcher can - // safely ignore the event. + } + function updateIndexAndSyncDelete(uri: vscode.Uri): void { + if (notToSync(uri)) { return; } const uriString = uri.toString(); @@ -310,21 +295,25 @@ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Pr .get("syncLocalChanges"); const sync: boolean = api.active && (syncLocalChanges == "all" || (syncLocalChanges == "vscodeOnly" && touchedByVSCode.has(uriString))); - touchedByVSCode.delete(uriString); - if (isClassOrRtn(uri)) { - // Remove the class/routine in the file from the index, - // then delete it on the server if required - const change = removeDocumentFromIndex(uri, documents, uris); - if (sync && change.removed) { - debouncedDelete(change.removed); + for (const subUriString of uris.keys()) { + touchedByVSCode.delete(subUriString); + const subUri = vscode.Uri.parse(subUriString); + if (!uriIsAncestorOf(uri, subUri)) { + continue; + } + if (sync) { + // Remove the class/routine, web application file, or Studio abstract document from the index, + // then delete it on the server if required + const change = removeDocumentFromIndex(subUri, documents, uris); + if (change.removed) { + debouncedDelete(change.removed); + } } - } else if (sync && isImportableLocalFile(uri)) { - // Delete this web application file or Studio abstract document on the server - const docName = getServerDocName(uri); - if (!docName) return; - debouncedDelete(docName); } - }); + } + watcher.onDidChange((uri) => restRateLimiter.call(() => updateIndexAndSyncChange(uri))); + watcher.onDidCreate((uri) => restRateLimiter.call(() => updateIndexAndSyncChange(uri, true))); + watcher.onDidDelete(updateIndexAndSyncDelete); wsFolderIndex.set(wsFolder.uri.toString(), { watcher, documents, uris }); } @@ -341,46 +330,50 @@ export function removeIndexOfWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): * Update the entries in the index for `uri`. `content` will only be passed if this * function is called for a document that was just exported from the server. */ -export async function updateIndexForDocument( +export async function updateIndex(uri: vscode.Uri, content?: string[] | Buffer): Promise { + const wsFolder = vscode.workspace.getWorkspaceFolder(uri); + if (!wsFolder) return {}; + const index = wsFolderIndex.get(wsFolder.uri.toString()); + if (!index) return {}; + return updateIndexInternal(uri, index.documents, index.uris, true, content); +} + +async function updateIndexInternal( uri: vscode.Uri, - documents?: Map, - uris?: Map, + documents: WSFolderIndex["documents"], + uris: WSFolderIndex["uris"], + sync: boolean, content?: string[] | Buffer ): Promise { const result: WSFolderIndexChange = {}; const uriString = uri.toString(); - if (!documents) { - const wsFolder = vscode.workspace.getWorkspaceFolder(uri); - if (!wsFolder) return result; - const index = wsFolderIndex.get(wsFolder.uri.toString()); - if (!index) return result; - documents = index.documents; - uris = index.uris; - } - const documentName = uris.get(uriString); const file = await getCurrentFile(uri, true, content); if (!file) return result; result.addedOrChanged = file; - // This file contains an InterSystems document, so add it to the index - if (!documentName || (documentName && documentName != file.name)) { - const documentUris = documents.get(file.name) ?? []; - if (documentUris.some((u) => u.toString() == uriString)) return result; - documentUris.push(uri); - documents.set(file.name, documentUris); - uris.set(uriString, file.name); - if (documentName) { - // Remove the outdated reference - const oldDocumentUris = documents.get(documentName); - if (!oldDocumentUris) return result; - const idx = oldDocumentUris.findIndex((f) => f.toString() == uriString); - if (idx == -1) return result; - if (documentUris.length > 1) { - documentUris.splice(idx, 1); - documents.set(documentName, documentUris); - } else { - documents.delete(documentName); - result.removed = documentName; - } + if (isImportableLocalFile(uri) && sync) { + sendClientSideSyncTelemetryEvent(file.fileName.split(".").pop().toLowerCase()); + } + const documentUris = documents.get(file.name) ?? []; + if (documentUris.some((u) => u.toString() == uriString)) { + // No need to update the index since this document is already present + return result; + } + documentUris.push(uri); + documents.set(file.name, documentUris); + const documentName = uris.get(uriString); + uris.set(uriString, file.name); + if (documentName) { + // Remove the outdated reference + const oldDocumentUris = documents.get(documentName); + if (!oldDocumentUris) return result; + const idx = oldDocumentUris.findIndex((f) => f.toString() == uriString); + if (idx == -1) return result; + if (documentUris.length > 1) { + documentUris.splice(idx, 1); + documents.set(documentName, documentUris); + } else { + documents.delete(documentName); + result.removed = documentName; } } return result; @@ -389,19 +382,11 @@ export async function updateIndexForDocument( /** Remove the entries in the index for `uri` */ function removeDocumentFromIndex( uri: vscode.Uri, - documents?: Map, - uris?: Map + documents: Map, + uris: Map ): WSFolderIndexChange { const result: WSFolderIndexChange = {}; const uriString = uri.toString(); - if (!documents) { - const wsFolder = vscode.workspace.getWorkspaceFolder(uri); - if (!wsFolder) return result; - const index = wsFolderIndex.get(wsFolder.uri.toString()); - if (!index) return result; - documents = index.documents; - uris = index.uris; - } const documentName = uris.get(uriString); if (!documentName) return result; // Remove it from the index diff --git a/src/utils/index.ts b/src/utils/index.ts index 6a2eafb6..58d4828a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -229,7 +229,7 @@ export function currentFileFromContent(uri: vscode.Uri, content: string | Buffer const fileExt = fileName.split(".").pop().toLowerCase(); if ( notIsfs(uri) && - !isClassOrRtn(uri) && + !isClassOrRtn(uri.path) && // This is a non-class or routine local file, so check if we can import it !isImportableLocalFile(uri) ) { @@ -292,7 +292,7 @@ export function currentFile(document?: vscode.TextDocument): CurrentTextFile { const fileExt = fileName.split(".").pop().toLowerCase(); if ( notIsfs(document.uri) && - !isClassOrRtn(document.uri) && + !isClassOrRtn(document.uri.path) && // This is a non-class or routine local file, so check if we can import it !isImportableLocalFile(document.uri) ) { @@ -801,16 +801,15 @@ export function parseClassMemberDefinition( }; } -/** Returns `true` if `uri1` is a parent of `uri2`. */ -export function uriIsParentOf(uri1: vscode.Uri, uri2: vscode.Uri): boolean { - uri1 = uri1.with({ path: !uri1.path.endsWith("/") ? `${uri1.path}/` : uri1.path }); +/** Returns `true` if `uri1` is equal to or an ancestor of `uri2`. + * Non-path components (e.g., scheme, fragment, and query) must be identical. + */ +export function uriIsAncestorOf(uri1: vscode.Uri, uri2: vscode.Uri): boolean { return ( - uri2 - .with({ query: "", fragment: "" }) - .toString() - .startsWith(uri1.with({ query: "", fragment: "" }).toString()) && - uri1.query == uri2.query && - uri1.fragment == uri2.fragment + uri1.with({ path: "" }).toString == uri2.with({ path: "" }).toString && + // uri2.path "properly" starts with uri1.path. + uri2.path.startsWith(uri1.path) && + (uri1.path.endsWith("/") || [undefined, "/"].includes(uri2.path.at(uri1.path.length))) ); } @@ -887,10 +886,8 @@ export function base64EncodeContent(content: Buffer): string[] { } /** Returns `true` if `uri` has a class or routine file extension */ -export function isClassOrRtn(uriOrName: vscode.Uri | string): boolean { - return ["cls", "mac", "int", "inc"].includes( - (uriOrName instanceof vscode.Uri ? uriOrName.path : uriOrName).split(".").pop().toLowerCase() - ); +export function isClassOrRtn(uriOrName: string): boolean { + return ["cls", "mac", "int", "inc"].includes(uriOrName.split(".").pop().toLowerCase()); } interface ConnQPItem extends vscode.QuickPickItem {