Skip to content

Commit e098303

Browse files
authored
Support only syncing local changes made form VS Code (#1520)
1 parent 47ae26f commit e098303

File tree

8 files changed

+130
-36
lines changed

8 files changed

+130
-36
lines changed

package.json

+13-3
Original file line numberDiff line numberDiff line change
@@ -1602,9 +1602,19 @@
16021602
"default": "command"
16031603
},
16041604
"objectscript.syncLocalChanges": {
1605-
"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).",
1606-
"type": "boolean",
1607-
"default": true
1605+
"description": "Controls the sources of file events (changes, creation, deletion) in client-side workspace folders that trigger automatic synchronization with the server.",
1606+
"type": "string",
1607+
"enum": [
1608+
"all",
1609+
"vscodeOnly",
1610+
"none"
1611+
],
1612+
"enumDescriptions": [
1613+
"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).",
1614+
"Only file events made due to user actions in VS Code are automatically synced to the server.",
1615+
"No file events are automatically synced to the server."
1616+
],
1617+
"default": "all"
16081618
},
16091619
"objectscript.outputRESTTraffic": {
16101620
"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.",

src/commands/compile.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
notNull,
3131
outputChannel,
3232
RateLimiter,
33+
replaceFile,
3334
routineNameTypeRegex,
3435
} from "../utils";
3536
import { StudioActions } from "./studio";
@@ -226,15 +227,17 @@ export async function loadChanges(files: (CurrentTextFile | CurrentBinaryFile)[]
226227
workspaceState.update(`${file.uniqueId}:mtime`, mtime > 0 ? mtime : undefined);
227228
if (notIsfs(file.uri)) {
228229
const content = await api.getDoc(file.name).then((data) => data.result.content);
229-
await vscode.workspace.fs.writeFile(
230-
file.uri,
231-
Buffer.isBuffer(content) ? content : new TextEncoder().encode(content.join("\n"))
232-
);
230+
exportedUris.add(file.uri.toString()); // Set optimistically
231+
await replaceFile(file.uri, content).catch((e) => {
232+
// Save failed, so remove this URI from the set
233+
exportedUris.delete(file.uri.toString());
234+
// Re-throw the error
235+
throw e;
236+
});
233237
if (isClassOrRtn(file.uri)) {
234238
// Update the document index
235239
updateIndexForDocument(file.uri, undefined, undefined, content);
236240
}
237-
exportedUris.push(file.uri.toString());
238241
} else if (filesystemSchemas.includes(file.uri.scheme)) {
239242
fileSystemProvider.fireFileChanged(file.uri);
240243
}

src/commands/export.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
notNull,
1313
outputChannel,
1414
RateLimiter,
15+
replaceFile,
1516
stringifyError,
1617
uriOfWorkspaceFolder,
1718
workspaceFolderOfUri,
@@ -103,15 +104,17 @@ async function exportFile(wsFolderUri: vscode.Uri, namespace: string, name: stri
103104
throw new Error("Received malformed JSON object from server fetching document");
104105
}
105106
const content = data.result.content;
106-
await vscode.workspace.fs.writeFile(
107-
fileUri,
108-
Buffer.isBuffer(content) ? content : new TextEncoder().encode(content.join("\n"))
109-
);
107+
exportedUris.add(fileUri.toString()); // Set optimistically
108+
await replaceFile(fileUri, content).catch((e) => {
109+
// Save failed, so remove this URI from the set
110+
exportedUris.delete(fileUri.toString());
111+
// Re-throw the error
112+
throw e;
113+
});
110114
if (isClassOrRtn(fileUri)) {
111115
// Update the document index
112116
updateIndexForDocument(fileUri, undefined, undefined, content);
113117
}
114-
exportedUris.push(fileUri.toString());
115118
const ws = workspaceFolderOfUri(fileUri);
116119
const mtime = Number(new Date(data.result.ts + "Z"));
117120
if (ws) await workspaceState.update(`${ws}:${name}:mtime`, mtime > 0 ? mtime : undefined);
@@ -377,7 +380,7 @@ export async function exportDocumentsToXMLFile(): Promise<void> {
377380
// Get the XML content
378381
const xmlContent = await api.actionXMLExport(documents).then((data) => data.result.content);
379382
// Save the file
380-
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(xmlContent.join("\n")));
383+
await replaceFile(uri, xmlContent);
381384
}
382385
} catch (error) {
383386
handleError(error, "Error executing 'Export Documents to XML File...' command.");

src/commands/newFile.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path = require("path");
33
import { AtelierAPI } from "../api";
44
import { FILESYSTEM_SCHEMA } from "../extension";
55
import { DocumentContentProvider } from "../providers/DocumentContentProvider";
6-
import { getWsFolder, handleError } from "../utils";
6+
import { replaceFile, getWsFolder, handleError } from "../utils";
77
import { getFileName } from "./export";
88
import { getUrisForDocument } from "../utils/documentIndex";
99

@@ -847,8 +847,8 @@ ClassMethod %OnDashboardAction(pAction As %String, pContext As %ZEN.proxyObject)
847847
}
848848

849849
if (clsUri && clsContent) {
850-
// Write the file content
851-
await vscode.workspace.fs.writeFile(clsUri, new TextEncoder().encode(clsContent.trimStart()));
850+
// Create the file
851+
await replaceFile(clsUri, clsContent.trimStart());
852852
// Show the file
853853
vscode.window.showTextDocument(clsUri, { preview: false });
854854
}

src/commands/xmlToUdl.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as vscode from "vscode";
22
import path = require("path");
33
import { config, OBJECTSCRIPTXML_FILE_SCHEMA, xmlContentProvider } from "../extension";
44
import { AtelierAPI } from "../api";
5-
import { fileExists, getWsFolder, handleError, notIsfs, outputChannel } from "../utils";
5+
import { replaceFile, fileExists, getWsFolder, handleError, notIsfs, outputChannel } from "../utils";
66
import { getFileName } from "./export";
77

88
const exportHeader = /^\s*<Export generator="(Cache|IRIS)" version="\d+"/;
@@ -169,7 +169,6 @@ export async function extractXMLFileContents(xmlUri?: vscode.Uri): Promise<void>
169169
const { atelier, folder, addCategory, map } = config("export", wsFolder.name);
170170
const rootFolder =
171171
wsFolder.uri.path + (typeof folder == "string" && folder.length ? `/${folder.replaceAll(path.sep, "/")}` : "");
172-
const textEncoder = new TextEncoder();
173172
let errs = 0;
174173
for (const udlDoc of udlDocs) {
175174
if (!docWhitelist.includes(udlDoc.name)) continue; // This file wasn't selected
@@ -180,7 +179,7 @@ export async function extractXMLFileContents(xmlUri?: vscode.Uri): Promise<void>
180179
continue;
181180
}
182181
try {
183-
await vscode.workspace.fs.writeFile(fileUri, textEncoder.encode(udlDoc.content.join("\n")));
182+
await replaceFile(fileUri, udlDoc.content);
184183
} catch (error) {
185184
outputChannel.appendLine(
186185
typeof error == "string" ? error : error instanceof Error ? error.toString() : JSON.stringify(error)

src/extension.ts

+37-3
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import {
104104
isClassOrRtn,
105105
addWsServerRootFolderData,
106106
getWsFolder,
107+
replaceFile,
107108
} from "./utils";
108109
import { ObjectScriptDiagnosticProvider } from "./providers/ObjectScriptDiagnosticProvider";
109110
import { DocumentLinkProvider } from "./providers/DocumentLinkProvider";
@@ -148,6 +149,7 @@ import {
148149
disposeDocumentIndex,
149150
indexWorkspaceFolder,
150151
removeIndexOfWorkspaceFolder,
152+
storeTouchedByVSCode,
151153
updateIndexForDocument,
152154
} from "./utils/documentIndex";
153155
import { WorkspaceNode, NodeBase } from "./explorer/nodes";
@@ -954,13 +956,13 @@ export async function activate(context: vscode.ExtensionContext): Promise<any> {
954956
const importOnSave = conf.inspect("importOnSave");
955957
if (typeof importOnSave.globalValue == "boolean") {
956958
if (!importOnSave.globalValue) {
957-
conf.update("syncLocalChanges", false, vscode.ConfigurationTarget.Global);
959+
conf.update("syncLocalChanges", "off", vscode.ConfigurationTarget.Global);
958960
}
959961
conf.update("importOnSave", undefined, vscode.ConfigurationTarget.Global);
960962
}
961963
if (typeof importOnSave.workspaceValue == "boolean") {
962964
if (!importOnSave.workspaceValue) {
963-
conf.update("syncLocalChanges", false, vscode.ConfigurationTarget.Workspace);
965+
conf.update("syncLocalChanges", "off", vscode.ConfigurationTarget.Workspace);
964966
}
965967
conf.update("importOnSave", undefined, vscode.ConfigurationTarget.Workspace);
966968
}
@@ -1270,7 +1272,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<any> {
12701272
// Generate the new content
12711273
const newContent = generateFileContent(uri, fileName, sourceContent);
12721274
// Write the new content to the file
1273-
return vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(newContent.content.join("\n")));
1275+
return replaceFile(uri, newContent.content);
12741276
})
12751277
);
12761278
}),
@@ -1606,6 +1608,38 @@ export async function activate(context: vscode.ExtensionContext): Promise<any> {
16061608
if (typeof args != "object") return;
16071609
showPlanWebview(args);
16081610
}),
1611+
// These three listeners are needed to keep track of which file events were caused by VS Code
1612+
// to support the "vscodeOnly" option for the objectscript.syncLocalChanges setting.
1613+
// They store the URIs of files that are about to be changed by VS Code.
1614+
// The curresponding file system watcher listener in documentIndex.ts will pick up the
1615+
// event after these listeners are called, and it removes the affected URIs from the Set.
1616+
// The "waitUntil" Promises are needed to ensure that these listeners complete
1617+
// before the file system watcher listeners are called. This should not have any noticable
1618+
// effect on the user experience since the Promises will resolve very quickly.
1619+
vscode.workspace.onWillSaveTextDocument((e) =>
1620+
e.waitUntil(
1621+
new Promise<void>((resolve) => {
1622+
storeTouchedByVSCode(e.document.uri);
1623+
resolve();
1624+
})
1625+
)
1626+
),
1627+
vscode.workspace.onWillCreateFiles((e) =>
1628+
e.waitUntil(
1629+
new Promise<void>((resolve) => {
1630+
e.files.forEach((f) => storeTouchedByVSCode(f));
1631+
resolve();
1632+
})
1633+
)
1634+
),
1635+
vscode.workspace.onWillDeleteFiles((e) =>
1636+
e.waitUntil(
1637+
new Promise<void>((resolve) => {
1638+
e.files.forEach((f) => storeTouchedByVSCode(f));
1639+
resolve();
1640+
})
1641+
)
1642+
),
16091643

16101644
/* Anything we use from the VS Code proposed API */
16111645
...proposed

src/utils/documentIndex.ts

+38-10
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,17 @@ function generateDeleteFn(wsFolderUri: vscode.Uri): (doc: string) => void {
142142
};
143143
}
144144

145+
/** The stringified URIs of all files that were touched by VS Code */
146+
const touchedByVSCode: Set<string> = new Set();
147+
148+
/** Keep track that `uri` was touched by VS Code if it's in a client-side workspace folder */
149+
export function storeTouchedByVSCode(uri: vscode.Uri): void {
150+
const wsFolder = vscode.workspace.getWorkspaceFolder(uri);
151+
if (wsFolder && notIsfs(wsFolder.uri) && uri.scheme == wsFolder.uri.scheme) {
152+
touchedByVSCode.add(uri.toString());
153+
}
154+
}
155+
145156
/** Create index of `wsFolder` and set up a `FileSystemWatcher` to keep the index up to date */
146157
export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Promise<void> {
147158
if (!notIsfs(wsFolder.uri)) return;
@@ -161,31 +172,37 @@ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Pr
161172
const debouncedCompile = generateCompileFn();
162173
const debouncedDelete = generateDeleteFn(wsFolder.uri);
163174
const updateIndexAndSyncChanges = async (uri: vscode.Uri): Promise<void> => {
175+
if (uri.scheme != wsFolder.uri.scheme) {
176+
// We don't care about virtual files that might be
177+
// part of the workspace folder, like "git" files
178+
return;
179+
}
164180
const uriString = uri.toString();
165181
if (openCustomEditors.includes(uriString)) {
166182
// This class is open in a graphical editor, so its name will not change
167183
// and any updates to the class will be handled by that editor
168184
return;
169185
}
170-
const exportedIdx = exportedUris.findIndex((e) => e == uriString);
171-
if (exportedIdx != -1) {
186+
if (exportedUris.has(uriString)) {
172187
// This creation/change event was fired due to a server
173188
// export, so don't re-sync the file with the server.
174189
// The index has already been updated.
175-
exportedUris.splice(exportedIdx, 1);
190+
exportedUris.delete(uriString);
176191
return;
177192
}
178-
const conf = vscode.workspace.getConfiguration("objectscript", uri);
179-
const sync: boolean = conf.get("syncLocalChanges");
193+
const api = new AtelierAPI(uri);
194+
const conf = vscode.workspace.getConfiguration("objectscript", wsFolder);
195+
const syncLocalChanges: string = conf.get("syncLocalChanges");
196+
const sync: boolean =
197+
api.active && (syncLocalChanges == "all" || (syncLocalChanges == "vscodeOnly" && touchedByVSCode.has(uriString)));
198+
touchedByVSCode.delete(uriString);
180199
let change: WSFolderIndexChange = {};
181200
if (isClassOrRtn(uri)) {
182201
change = await updateIndexForDocument(uri, documents, uris);
183202
} else if (sync && isImportableLocalFile(uri)) {
184203
change.addedOrChanged = await getCurrentFile(uri);
185204
}
186205
if (!sync || (!change.addedOrChanged && !change.removed)) return;
187-
const api = new AtelierAPI(uri);
188-
if (!api.active) return;
189206
if (change.addedOrChanged) {
190207
// Create or update the document on the server
191208
importFile(change.addedOrChanged)
@@ -203,16 +220,27 @@ export async function indexWorkspaceFolder(wsFolder: vscode.WorkspaceFolder): Pr
203220
watcher.onDidChange((uri) => restRateLimiter.call(() => updateIndexAndSyncChanges(uri)));
204221
watcher.onDidCreate((uri) => restRateLimiter.call(() => updateIndexAndSyncChanges(uri)));
205222
watcher.onDidDelete((uri) => {
206-
const sync: boolean = vscode.workspace.getConfiguration("objectscript", uri).get("syncLocalChanges");
223+
if (uri.scheme != wsFolder.uri.scheme) {
224+
// We don't care about virtual files that might be
225+
// part of the workspace folder, like "git" files
226+
return;
227+
}
228+
const uriString = uri.toString();
207229
const api = new AtelierAPI(uri);
230+
const syncLocalChanges: string = vscode.workspace
231+
.getConfiguration("objectscript", wsFolder)
232+
.get("syncLocalChanges");
233+
const sync: boolean =
234+
api.active && (syncLocalChanges == "all" || (syncLocalChanges == "vscodeOnly" && touchedByVSCode.has(uriString)));
235+
touchedByVSCode.delete(uriString);
208236
if (isClassOrRtn(uri)) {
209237
// Remove the class/routine in the file from the index,
210238
// then delete it on the server if required
211239
const change = removeDocumentFromIndex(uri, documents, uris);
212-
if (sync && api.active && change.removed) {
240+
if (sync && change.removed) {
213241
debouncedDelete(change.removed);
214242
}
215-
} else if (sync && api.active && isImportableLocalFile(uri)) {
243+
} else if (sync && isImportableLocalFile(uri)) {
216244
// Delete this web application file or Studio abstract document on the server
217245
const docName = getServerDocName(uri);
218246
if (!docName) return;

src/utils/index.ts

+20-3
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ export const otherDocExts: Map<string, string[]> = new Map();
3939
export const openCustomEditors: string[] = [];
4040

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

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

954+
/**
955+
* Replace the contents `uri` with `content` using the `workspace.applyEdit()` API.
956+
* That API is used so the change fires "onWill" and "onDid" events.
957+
* Will overwrite the file if it exists and create the file if it doesn't.
958+
*/
959+
export async function replaceFile(uri: vscode.Uri, content: string | string[] | Buffer): Promise<void> {
960+
const wsEdit = new vscode.WorkspaceEdit();
961+
wsEdit.createFile(uri, {
962+
overwrite: true,
963+
contents: Buffer.isBuffer(content)
964+
? content
965+
: new TextEncoder().encode(Array.isArray(content) ? content.join("\n") : content),
966+
});
967+
const success = await vscode.workspace.applyEdit(wsEdit);
968+
if (!success) throw `Failed to create or replace contents of file '${uri.toString(true)}'`;
969+
}
970+
954971
class Semaphore {
955972
/** Queue of tasks waiting to acquire the semaphore */
956973
private _tasks: (() => void)[] = [];

0 commit comments

Comments
 (0)