Skip to content

Support only syncing local changes made form VS Code #1520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1602,9 +1602,19 @@
"default": "command"
},
"objectscript.syncLocalChanges": {
"description": "Controls whether files in client-side workspace folders that are created, deleted, or changed are automatically synced to the server. Synching will occur whether changes are made due to user actions in VS Code (for example, saving a file that's being actively edited) or outside of VS Code (for example, deleting a file from the OS file explorer while VS Code has its folder open).",
"type": "boolean",
"default": true
"description": "Controls the sources of file events (changes, creation, deletion) in client-side workspace folders that trigger automatic synchronization with the server.",
"type": "string",
"enum": [
"all",
"vscodeOnly",
"none"
],
"enumDescriptions": [
"All file events are automatically synced to the server, whether made due to user actions in VS Code (for example, saving a file that's being actively edited) or outside of VS Code (for example, deleting a file from the OS file explorer while VS Code has its folder open).",
"Only file events made due to user actions in VS Code are automatically synced to the server.",
"No file events are automatically synced to the server."
],
"default": "all"
},
"objectscript.outputRESTTraffic": {
"description": "If true, REST requests and responses to and from InterSystems servers will be logged to the ObjectScript Output channel. This should only be enabled when debugging a potential issue.",
Expand Down
13 changes: 8 additions & 5 deletions src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
notNull,
outputChannel,
RateLimiter,
replaceFile,
routineNameTypeRegex,
} from "../utils";
import { StudioActions } from "./studio";
Expand Down Expand Up @@ -226,15 +227,17 @@ export async function loadChanges(files: (CurrentTextFile | CurrentBinaryFile)[]
workspaceState.update(`${file.uniqueId}:mtime`, mtime > 0 ? mtime : undefined);
if (notIsfs(file.uri)) {
const content = await api.getDoc(file.name).then((data) => data.result.content);
await vscode.workspace.fs.writeFile(
file.uri,
Buffer.isBuffer(content) ? content : new TextEncoder().encode(content.join("\n"))
);
exportedUris.add(file.uri.toString()); // Set optimistically
await replaceFile(file.uri, content).catch((e) => {
// Save failed, so remove this URI from the set
exportedUris.delete(file.uri.toString());
// Re-throw the error
throw e;
});
if (isClassOrRtn(file.uri)) {
// Update the document index
updateIndexForDocument(file.uri, undefined, undefined, content);
}
exportedUris.push(file.uri.toString());
} else if (filesystemSchemas.includes(file.uri.scheme)) {
fileSystemProvider.fireFileChanged(file.uri);
}
Expand Down
15 changes: 9 additions & 6 deletions src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
notNull,
outputChannel,
RateLimiter,
replaceFile,
stringifyError,
uriOfWorkspaceFolder,
workspaceFolderOfUri,
Expand Down Expand Up @@ -103,15 +104,17 @@ async function exportFile(wsFolderUri: vscode.Uri, namespace: string, name: stri
throw new Error("Received malformed JSON object from server fetching document");
}
const content = data.result.content;
await vscode.workspace.fs.writeFile(
fileUri,
Buffer.isBuffer(content) ? content : new TextEncoder().encode(content.join("\n"))
);
exportedUris.add(fileUri.toString()); // Set optimistically
await replaceFile(fileUri, content).catch((e) => {
// Save failed, so remove this URI from the set
exportedUris.delete(fileUri.toString());
// Re-throw the error
throw e;
});
if (isClassOrRtn(fileUri)) {
// Update the document index
updateIndexForDocument(fileUri, undefined, undefined, content);
}
exportedUris.push(fileUri.toString());
const ws = workspaceFolderOfUri(fileUri);
const mtime = Number(new Date(data.result.ts + "Z"));
if (ws) await workspaceState.update(`${ws}:${name}:mtime`, mtime > 0 ? mtime : undefined);
Expand Down Expand Up @@ -377,7 +380,7 @@ export async function exportDocumentsToXMLFile(): Promise<void> {
// Get the XML content
const xmlContent = await api.actionXMLExport(documents).then((data) => data.result.content);
// Save the file
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(xmlContent.join("\n")));
await replaceFile(uri, xmlContent);
}
} catch (error) {
handleError(error, "Error executing 'Export Documents to XML File...' command.");
Expand Down
6 changes: 3 additions & 3 deletions src/commands/newFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path = require("path");
import { AtelierAPI } from "../api";
import { FILESYSTEM_SCHEMA } from "../extension";
import { DocumentContentProvider } from "../providers/DocumentContentProvider";
import { getWsFolder, handleError } from "../utils";
import { replaceFile, getWsFolder, handleError } from "../utils";
import { getFileName } from "./export";
import { getUrisForDocument } from "../utils/documentIndex";

Expand Down Expand Up @@ -847,8 +847,8 @@ ClassMethod %OnDashboardAction(pAction As %String, pContext As %ZEN.proxyObject)
}

if (clsUri && clsContent) {
// Write the file content
await vscode.workspace.fs.writeFile(clsUri, new TextEncoder().encode(clsContent.trimStart()));
// Create the file
await replaceFile(clsUri, clsContent.trimStart());
// Show the file
vscode.window.showTextDocument(clsUri, { preview: false });
}
Expand Down
5 changes: 2 additions & 3 deletions src/commands/xmlToUdl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as vscode from "vscode";
import path = require("path");
import { config, OBJECTSCRIPTXML_FILE_SCHEMA, xmlContentProvider } from "../extension";
import { AtelierAPI } from "../api";
import { fileExists, getWsFolder, handleError, notIsfs, outputChannel } from "../utils";
import { replaceFile, fileExists, getWsFolder, handleError, notIsfs, outputChannel } from "../utils";
import { getFileName } from "./export";

const exportHeader = /^\s*<Export generator="(Cache|IRIS)" version="\d+"/;
Expand Down Expand Up @@ -169,7 +169,6 @@ export async function extractXMLFileContents(xmlUri?: vscode.Uri): Promise<void>
const { atelier, folder, addCategory, map } = config("export", wsFolder.name);
const rootFolder =
wsFolder.uri.path + (typeof folder == "string" && folder.length ? `/${folder.replaceAll(path.sep, "/")}` : "");
const textEncoder = new TextEncoder();
let errs = 0;
for (const udlDoc of udlDocs) {
if (!docWhitelist.includes(udlDoc.name)) continue; // This file wasn't selected
Expand All @@ -180,7 +179,7 @@ export async function extractXMLFileContents(xmlUri?: vscode.Uri): Promise<void>
continue;
}
try {
await vscode.workspace.fs.writeFile(fileUri, textEncoder.encode(udlDoc.content.join("\n")));
await replaceFile(fileUri, udlDoc.content);
} catch (error) {
outputChannel.appendLine(
typeof error == "string" ? error : error instanceof Error ? error.toString() : JSON.stringify(error)
Expand Down
40 changes: 37 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import {
isClassOrRtn,
addWsServerRootFolderData,
getWsFolder,
replaceFile,
} from "./utils";
import { ObjectScriptDiagnosticProvider } from "./providers/ObjectScriptDiagnosticProvider";
import { DocumentLinkProvider } from "./providers/DocumentLinkProvider";
Expand Down Expand Up @@ -148,6 +149,7 @@ import {
disposeDocumentIndex,
indexWorkspaceFolder,
removeIndexOfWorkspaceFolder,
storeTouchedByVSCode,
updateIndexForDocument,
} from "./utils/documentIndex";
import { WorkspaceNode, NodeBase } from "./explorer/nodes";
Expand Down Expand Up @@ -954,13 +956,13 @@ export async function activate(context: vscode.ExtensionContext): Promise<any> {
const importOnSave = conf.inspect("importOnSave");
if (typeof importOnSave.globalValue == "boolean") {
if (!importOnSave.globalValue) {
conf.update("syncLocalChanges", false, vscode.ConfigurationTarget.Global);
conf.update("syncLocalChanges", "off", vscode.ConfigurationTarget.Global);
}
conf.update("importOnSave", undefined, vscode.ConfigurationTarget.Global);
}
if (typeof importOnSave.workspaceValue == "boolean") {
if (!importOnSave.workspaceValue) {
conf.update("syncLocalChanges", false, vscode.ConfigurationTarget.Workspace);
conf.update("syncLocalChanges", "off", vscode.ConfigurationTarget.Workspace);
}
conf.update("importOnSave", undefined, vscode.ConfigurationTarget.Workspace);
}
Expand Down Expand Up @@ -1270,7 +1272,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<any> {
// Generate the new content
const newContent = generateFileContent(uri, fileName, sourceContent);
// Write the new content to the file
return vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(newContent.content.join("\n")));
return replaceFile(uri, newContent.content);
})
);
}),
Expand Down Expand Up @@ -1606,6 +1608,38 @@ export async function activate(context: vscode.ExtensionContext): Promise<any> {
if (typeof args != "object") return;
showPlanWebview(args);
}),
// These three listeners are needed to keep track of which file events were caused by VS Code
// to support the "vscodeOnly" option for the objectscript.syncLocalChanges setting.
// They store the URIs of files that are about to be changed by VS Code.
// The curresponding file system watcher listener in documentIndex.ts will pick up the
// event after these listeners are called, and it removes the affected URIs from the Set.
// The "waitUntil" Promises are needed to ensure that these listeners complete
// before the file system watcher listeners are called. This should not have any noticable
// effect on the user experience since the Promises will resolve very quickly.
vscode.workspace.onWillSaveTextDocument((e) =>
e.waitUntil(
new Promise<void>((resolve) => {
storeTouchedByVSCode(e.document.uri);
resolve();
})
)
),
vscode.workspace.onWillCreateFiles((e) =>
e.waitUntil(
new Promise<void>((resolve) => {
e.files.forEach((f) => storeTouchedByVSCode(f));
resolve();
})
)
),
vscode.workspace.onWillDeleteFiles((e) =>
e.waitUntil(
new Promise<void>((resolve) => {
e.files.forEach((f) => storeTouchedByVSCode(f));
resolve();
})
)
),

/* Anything we use from the VS Code proposed API */
...proposed
Expand Down
48 changes: 38 additions & 10 deletions src/utils/documentIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ function generateDeleteFn(wsFolderUri: vscode.Uri): (doc: string) => void {
};
}

/** The stringified URIs of all files that were touched by VS Code */
const touchedByVSCode: Set<string> = new Set();

/** Keep track that `uri` was touched by VS Code if it's in a client-side workspace folder */
export function storeTouchedByVSCode(uri: vscode.Uri): void {
const wsFolder = vscode.workspace.getWorkspaceFolder(uri);
if (wsFolder && notIsfs(wsFolder.uri) && uri.scheme == wsFolder.uri.scheme) {
touchedByVSCode.add(uri.toString());
}
}

/** Create index of `wsFolder` and set up a `FileSystemWatcher` to keep the index up to date */
export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Promise<void> {
if (!notIsfs(wsFolder.uri)) return;
Expand All @@ -161,31 +172,37 @@ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Pr
const debouncedCompile = generateCompileFn();
const debouncedDelete = generateDeleteFn(wsFolder.uri);
const updateIndexAndSyncChanges = async (uri: vscode.Uri): Promise<void> => {
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;
}
const uriString = uri.toString();
if (openCustomEditors.includes(uriString)) {
// This class is open in a graphical editor, so its name will not change
// and any updates to the class will be handled by that editor
return;
}
const exportedIdx = exportedUris.findIndex((e) => e == uriString);
if (exportedIdx != -1) {
if (exportedUris.has(uriString)) {
// This creation/change event was fired due to a server
// export, so don't re-sync the file with the server.
// The index has already been updated.
exportedUris.splice(exportedIdx, 1);
exportedUris.delete(uriString);
return;
}
const conf = vscode.workspace.getConfiguration("objectscript", uri);
const sync: boolean = conf.get("syncLocalChanges");
const api = new AtelierAPI(uri);
const conf = vscode.workspace.getConfiguration("objectscript", wsFolder);
const syncLocalChanges: string = conf.get("syncLocalChanges");
const sync: boolean =
api.active && (syncLocalChanges == "all" || (syncLocalChanges == "vscodeOnly" && touchedByVSCode.has(uriString)));
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 (!sync || (!change.addedOrChanged && !change.removed)) return;
const api = new AtelierAPI(uri);
if (!api.active) return;
if (change.addedOrChanged) {
// Create or update the document on the server
importFile(change.addedOrChanged)
Expand All @@ -203,16 +220,27 @@ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Pr
watcher.onDidChange((uri) => restRateLimiter.call(() => updateIndexAndSyncChanges(uri)));
watcher.onDidCreate((uri) => restRateLimiter.call(() => updateIndexAndSyncChanges(uri)));
watcher.onDidDelete((uri) => {
const sync: boolean = vscode.workspace.getConfiguration("objectscript", uri).get("syncLocalChanges");
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;
}
const uriString = uri.toString();
const api = new AtelierAPI(uri);
const syncLocalChanges: string = vscode.workspace
.getConfiguration("objectscript", wsFolder)
.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 && api.active && change.removed) {
if (sync && change.removed) {
debouncedDelete(change.removed);
}
} else if (sync && api.active && isImportableLocalFile(uri)) {
} else if (sync && isImportableLocalFile(uri)) {
// Delete this web application file or Studio abstract document on the server
const docName = getServerDocName(uri);
if (!docName) return;
Expand Down
23 changes: 20 additions & 3 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ export const otherDocExts: Map<string, string[]> = new Map();
export const openCustomEditors: string[] = [];

/**
* Array of stringified `Uri`s that have been exported.
* Set of stringified `Uri`s that have been exported.
* Used by the documentIndex to determine if a created/changed
* file needs to be synced with the server. If the documentIndex
* finds a match in this array, the element is then removed.
* finds a match in this set, the element is then removed.
*/
export const exportedUris: string[] = [];
export const exportedUris: Set<string> = new Set();

/** Validates routine labels and unquoted class member names */
export const identifierRegex = /^(?:%|\p{L})[\p{L}\d]*$/u;
Expand Down Expand Up @@ -951,6 +951,23 @@ export function lastUsedLocalUri(newValue?: vscode.Uri): vscode.Uri {
return _lastUsedLocalUri;
}

/**
* Replace the contents `uri` with `content` using the `workspace.applyEdit()` API.
* That API is used so the change fires "onWill" and "onDid" events.
* Will overwrite the file if it exists and create the file if it doesn't.
*/
export async function replaceFile(uri: vscode.Uri, content: string | string[] | Buffer): Promise<void> {
const wsEdit = new vscode.WorkspaceEdit();
wsEdit.createFile(uri, {
overwrite: true,
contents: Buffer.isBuffer(content)
? content
: new TextEncoder().encode(Array.isArray(content) ? content.join("\n") : content),
});
const success = await vscode.workspace.applyEdit(wsEdit);
if (!success) throw `Failed to create or replace contents of file '${uri.toString(true)}'`;
}

class Semaphore {
/** Queue of tasks waiting to acquire the semaphore */
private _tasks: (() => void)[] = [];
Expand Down