diff --git a/package.json b/package.json index 3d19e625..db4a85d8 100644 --- a/package.json +++ b/package.json @@ -1416,6 +1416,11 @@ "type": "boolean", "default": true }, + "objectscript.refreshClassesOnSync": { + "description": "Controls whether the entire content of client-side classes is replaced with the server copy after synchronizing with the server. If `false`, only the contents of Storage definitions are replaced.", + "type": "boolean", + "default": false + }, "objectscript.multilineMethodArgs": { "markdownDescription": "List method arguments on multiple lines, if the server supports it. **NOTE:** Only supported on IRIS 2019.1.2, 2020.1.1+, 2021.1.0+ and subsequent versions! On all other versions, this setting will have no effect.", "type": "boolean", diff --git a/src/api/atelier.d.ts b/src/api/atelier.d.ts index f79e92b0..d445c0ef 100644 --- a/src/api/atelier.d.ts +++ b/src/api/atelier.d.ts @@ -41,7 +41,7 @@ export interface Document { cat: "RTN" | "CLS" | "CSP" | "OTH"; status: string; enc: boolean; - flags: number; + flags: 0 | 1; content: string[] | Buffer; ext?: UserAction | UserAction[]; } diff --git a/src/api/index.ts b/src/api/index.ts index c8ee4eaa..d15bf669 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -626,7 +626,12 @@ export class AtelierAPI { } // api v1+ - public getDoc(name: string, scope: vscode.Uri | string, mtime?: number): Promise> { + public getDoc( + name: string, + scope: vscode.Uri | string, + mtime?: number, + storageOnly: boolean = false + ): Promise> { let params, headers; name = this.transformNameIfCsp(name); if ( @@ -642,6 +647,11 @@ export class AtelierAPI { .get("multilineMethodArgs") ) { params = { format: "udl-multiline" }; + } else { + params = {}; + } + if (storageOnly) { + params["storageOnly"] = "1"; } if (mtime && mtime > 0) { headers = { "IF-NONE-MATCH": new Date(mtime).toISOString().replace(/T|Z/g, " ").trim() }; @@ -664,7 +674,7 @@ export class AtelierAPI { name: string, data: { enc: boolean; content: string[]; mtime: number }, ignoreConflict?: boolean - ): Promise { + ): Promise> { const params = { ignoreConflict }; name = this.transformNameIfCsp(name); const headers = {}; diff --git a/src/commands/compile.ts b/src/commands/compile.ts index 10cf6a95..f55edf77 100644 --- a/src/commands/compile.ts +++ b/src/commands/compile.ts @@ -24,6 +24,7 @@ import { exportedUris, getWsFolder, handleError, + isClass, isClassDeployed, isClassOrRtn, isCompilable, @@ -37,6 +38,7 @@ import { import { StudioActions } from "./studio"; import { NodeBase, PackageNode, RootNode } from "../explorer/nodes"; import { getUrisForDocument, updateIndex } from "../utils/documentIndex"; +import { Document } from "../api/atelier"; /** * For files being locally edited, get and return its mtime timestamp from workspace-state cache if present there, @@ -80,8 +82,10 @@ export async function checkChangedOnServer(file: CurrentTextFile | CurrentBinary return mtime; } +// Synchronize the client version and the server version of the same file export async function importFile( file: CurrentTextFile | CurrentBinaryFile, + willCompile: boolean, ignoreConflict?: boolean, skipDeplCheck = false ): Promise { @@ -114,8 +118,8 @@ export async function importFile( mtime < 0 || (notIsfs(file.uri) && vscode.workspace.getConfiguration("objectscript", file.uri).get("overwriteServerChanges")); - return api - .putDoc( + try { + const data = await api.putDoc( file.name, { content, @@ -123,69 +127,77 @@ export async function importFile( mtime, }, ignoreConflict - ) - .then((data) => { - // Update cache entry - workspaceState.update(`${file.uniqueId}:mtime`, Number(new Date(data.result.ts + "Z"))); - - // In case another extension has used an 'objectscript://' uri to load a document read-only from the server, - // make it reload with what we just imported to the server. - const serverUri = DocumentContentProvider.getUri( - file.name, - file.workspaceFolder, - undefined, - false, - undefined, - true - ); - documentContentProvider.update(serverUri.with({ scheme: OBJECTSCRIPT_FILE_SCHEMA })); - }) - .catch((error) => { - if (error?.statusCode == 409) { - const choices: string[] = []; - if (!enc) { - choices.push("Compare"); - } - choices.push("Overwrite on Server", "Pull Server Changes", "Cancel"); - return vscode.window - .showErrorMessage( - `Failed to import '${file.name}': The version of the file on the server is newer. + ); + workspaceState.update(`${file.uniqueId}:mtime`, Number(new Date(data.result.ts + "Z"))); + if (!willCompile && isClass(file.name) && data.result.content.length) { + // In this case, the file must be a CLS and data.result.content must be the new Storage definitions + // (with the rest of the class if flags === 0) + const oldContent = new TextDecoder().decode(await vscode.workspace.fs.readFile(file.uri)); + const oldContentArray = oldContent.split(/\r?\n/); + const storage = Buffer.isBuffer(data.result.content) + ? new TextDecoder().decode(data.result.content).split(/\r?\n/) + : data.result.content; + const newContentArray = updateStorage(oldContentArray, storage); + if (oldContentArray.some((oldLine, index) => oldLine !== newContentArray[index])) { + const EOL = ((file)?.eol ?? vscode.EndOfLine.LF) == vscode.EndOfLine.CRLF ? "\r\n" : "\n"; + const newContent = newContentArray.join(EOL); + await vscode.workspace.fs.writeFile(file.uri, new TextEncoder().encode(newContent)); + } + } + // In case another extension has used an 'objectscript://' uri to load a document read-only from the server, + // make it reload with what we just imported to the server. + const serverUri = DocumentContentProvider.getUri( + file.name, + file.workspaceFolder, + undefined, + false, + undefined, + true + ); + documentContentProvider.update(serverUri.with({ scheme: OBJECTSCRIPT_FILE_SCHEMA })); + return; + } catch (error) { + if (error?.statusCode == 409) { + const choices: string[] = []; + if (!enc) { + choices.push("Compare"); + } + choices.push("Overwrite on Server", "Pull Server Changes", "Cancel"); + const action = await vscode.window.showErrorMessage( + `Failed to import '${file.name}': The version of the file on the server is newer. What do you want to do?`, - ...choices - ) - .then((action) => { - switch (action) { - case "Compare": - return vscode.commands - .executeCommand( - "vscode.diff", - vscode.Uri.file(file.name).with({ - scheme: OBJECTSCRIPT_FILE_SCHEMA, - authority: file.workspaceFolder, - query: file.name.includes("/") ? "csp" : "", - }), - file.uri, - `Server • ${file.name} ↔ Local • ${file.fileName}` - ) - .then(() => Promise.reject()); - case "Overwrite on Server": - // Clear cache entry - workspaceState.update(`${file.uniqueId}:mtime`, undefined); - // Overwrite - return importFile(file, true, true); - case "Pull Server Changes": - loadChanges([file]); - return Promise.reject(); - case "Cancel": - return Promise.reject(); - } - return Promise.reject(); - }); - } else { - handleError(error, `Failed to save file '${file.name}' on the server.`); - return Promise.reject(); + ...choices + ); + switch (action) { + case "Compare": + await vscode.commands.executeCommand( + "vscode.diff", + vscode.Uri.file(file.name).with({ + scheme: OBJECTSCRIPT_FILE_SCHEMA, + authority: file.workspaceFolder, + query: file.name.includes("/") ? "csp" : "", + }), + file.uri, + `Server • ${file.name} ↔ Local • ${file.fileName}` + ); + return Promise.reject(); + case "Overwrite on Server": + // Clear cache entry + workspaceState.update(`${file.uniqueId}:mtime`, undefined); + // Overwrite + return importFile(file, willCompile, true, true); + case "Pull Server Changes": + loadChanges([file]); + return Promise.reject(); + case "Cancel": + return Promise.reject(); } - }); + return Promise.reject(); + } else { + handleError(error, `Failed to save file '${file.name}' on the server.`); + return Promise.reject(); + } + } } function updateOthers(others: string[], baseUri: vscode.Uri) { @@ -218,7 +230,21 @@ export async function loadChanges(files: (CurrentTextFile | CurrentBinaryFile)[] const mtime = Number(new Date(doc.ts + "Z")); workspaceState.update(`${file.uniqueId}:mtime`, mtime > 0 ? mtime : undefined); if (notIsfs(file.uri)) { - const content = await api.getDoc(file.name, file.uri).then((data) => data.result.content); + let content: Document["content"]; + if ( + !( + isClass(file.uri.path) && + !vscode.workspace.getConfiguration("objectscript", file.uri).get("refreshClassesOnSync") + ) + ) { + content = (await api.getDoc(file.name, file.uri)).result.content; + } else { + // Insert/update the storage part of class definition. + content = new TextDecoder().decode(await vscode.workspace.fs.readFile(file.uri)).split(/\r?\n/); + let storage = (await api.getDoc(file.name, file.uri, undefined, true)).result.content; + storage = Buffer.isBuffer(storage) ? new TextDecoder().decode(storage).split(/\r?\n/) : storage; + content = updateStorage(content, storage); + } exportedUris.add(file.uri.toString()); // Set optimistically await vscode.workspace.fs .writeFile( @@ -250,6 +276,52 @@ export async function loadChanges(files: (CurrentTextFile | CurrentBinaryFile)[] ); } +function updateStorage(content: string[], storage: string[]): string[] { + const storageMap = storageToMap(storage); + let contentString = content.join("\n"); + contentString = contentString + // update existing Storages + .replaceAll(/\n(\s*storage\s+(\w+)\s*{\s*)([^}]*?)(\s*})/gim, (_match, beforeXML, name, _oldXML, afterXML) => { + const newXML = storageMap.get(name); + if (newXML === undefined) { + return ""; + } + storageMap.delete(name); + return "\n" + beforeXML + newXML + afterXML; + }); + contentString = contentString + // insert remaining Storages + .replace(/}\s*$/, (m) => { + for (const [name, content] of storageMap.entries()) { + m = `Storage ${name}\n{\n${content}\n}\n\n${m}`; + } + return m; + }); + return contentString.split("\n"); +} + +function storageToMap(storage: string[]): Map { + const map: Map = new Map(); + let k: string; + let v = []; + for (const line of storage) { + if (line.startsWith("Storage ")) { + k = line.slice("Storage ".length, line.length); + v = []; + } else if (k !== undefined) { + if (line === "{") { + continue; + } else if (line === "}") { + map.set(k, v.join("\n")); + k = undefined; + } else { + v.push(line); + } + } + } + return map; +} + export async function compile(docs: (CurrentTextFile | CurrentBinaryFile)[], askFlags = false): Promise { docs = docs.filter(notNull); if (!docs.length) return; @@ -318,14 +390,16 @@ export async function importAndCompile(document?: vscode.TextDocument, askFlags return; } - return ( - importFile(file) - .then(() => { - if (isCompilable(file.name)) compile([file], askFlags); - }) - // importFile handles any server errors - .catch(() => {}) - ); + try { + if (isCompilable(file.name)) { + await importFile(file, true); + compile([file], askFlags); + } else { + await importFile(file, false); + } + } catch { + // importFile handles any server errors + } } export async function compileOnly(document?: vscode.TextDocument, askFlags = false): Promise { @@ -395,28 +469,26 @@ async function importFiles(files: vscode.Uri[], noCompile = false) { await Promise.allSettled( files.map((uri) => rateLimiter.call(async () => { - return vscode.workspace.fs - .readFile(uri) - .then((contentBytes) => - currentFileFromContent( - uri, - isText(uri.path.split("/").pop(), Buffer.from(contentBytes)) - ? new TextDecoder().decode(contentBytes) - : Buffer.from(contentBytes) - ) - ) - .then((curFile) => { - if (curFile) { - if (typeof curFile.content == "string" && isCompilable(curFile.name)) toCompile.push(curFile); - return importFile(curFile).then(() => outputChannel.appendLine("Imported file: " + curFile.fileName)); - } - }); + const contentBytes = await vscode.workspace.fs.readFile(uri); + const curFile = currentFileFromContent( + uri, + isText(uri.path.split("/").pop(), Buffer.from(contentBytes)) + ? new TextDecoder().decode(contentBytes) + : Buffer.from(contentBytes) + ); + if (curFile) { + if (typeof curFile.content == "string" && isCompilable(curFile.name)) { + toCompile.push(curFile); + } + await importFile(curFile, !noCompile); + outputChannel.appendLine("Imported file: " + curFile.fileName); + } }) ) ); if (!noCompile && toCompile.length > 0) { - return compile(toCompile); + await compile(toCompile); } return; } diff --git a/src/extension.ts b/src/extension.ts index 389c26b1..6366a33c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -802,6 +802,7 @@ function sendWsFolderTelemetryEvent(wsFolders: readonly vscode.WorkspaceFolder[] "config.syncLocalChanges": !serverSide ? conf.get("syncLocalChanges") : undefined, dockerCompose: !serverSide ? String(typeof conf.get("conn.docker-compose") == "object") : undefined, "config.conn.links": String(Object.keys(conf.get("conn.links", {})).length), + "config.refreshClassesOnSync": !serverSide ? conf.get("refreshClassesOnSync") : undefined, }); }); } diff --git a/src/utils/documentIndex.ts b/src/utils/documentIndex.ts index 82c0d840..f2c74026 100644 --- a/src/utils/documentIndex.ts +++ b/src/utils/documentIndex.ts @@ -262,22 +262,23 @@ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Pr if (!sync || (!change.addedOrChanged && !change.removed)) return; if (change.addedOrChanged) { // Create or update the document on the server - importFile(change.addedOrChanged) - .then(() => { - outputImport(change.addedOrChanged.name, uri); - if (conf.get("compileOnSave") && isCompilable(change.addedOrChanged.name)) { - // Compile right away if this document is in the active text editor. - // This is needed to avoid noticeable latency when a user is editing - // a client-side file, saves it, and the auto-compile kicks in. - if (vscodeChange && vscode.window.activeTextEditor?.document.uri.toString() == uriString) { - compile([change.addedOrChanged]); - } else { - debouncedCompile(change.addedOrChanged); - } + try { + const willCompile = conf.get("compileOnSave") && isCompilable(change.addedOrChanged.name); + await importFile(change.addedOrChanged, willCompile); + outputImport(change.addedOrChanged.name, uri); + if (willCompile) { + // Compile right away if this document is in the active text editor. + // This is needed to avoid noticeable latency when a user is editing + // a client-side file, saves it, and the auto-compile kicks in. + if (vscodeChange && vscode.window.activeTextEditor?.document.uri.toString() == uriString) { + compile([change.addedOrChanged]); + } else { + debouncedCompile(change.addedOrChanged); } - }) + } + } catch { // importFile handles any server errors - .catch(() => {}); + } } if (change.removed) { // Delete document on the server diff --git a/src/utils/index.ts b/src/utils/index.ts index e95d90fd..e9141e51 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -885,6 +885,11 @@ export function base64EncodeContent(content: Buffer): string[] { return result; } +/** Returns `true` if `uri` has a class file extension */ +export function isClass(uriOrName: string): boolean { + return "cls" == uriOrName.split(".").pop().toLowerCase(); +} + /** Returns `true` if `uri` has a class or routine file extension */ export function isClassOrRtn(uriOrName: string): boolean { return ["cls", "mac", "int", "inc"].includes(uriOrName.split(".").pop().toLowerCase());