From 36dda01c15a9415051b6fa446e12c1c2bcc8e1c2 Mon Sep 17 00:00:00 2001
From: "li.yunhao"
Date: Mon, 17 Aug 2026 10:21:50 +0800
Subject: [PATCH 1/6] fix: harden local replica synchronization
---
.vscodeignore | 1 +
AGENTS.md | 18 +
docs/anatomy.md | 5 +-
docs/wiki.md | 4 +
package.nls.json | 2 +-
src/api/base.ts | 67 ++-
src/api/socketio.ts | 26 +-
src/api/socketioAlt.ts | 27 +-
src/compile/compileManager.ts | 7 +-
src/core/remoteFileSystemProvider.ts | 128 +++--
src/extension.ts | 7 +-
src/intellisense/texDocumentParseUtility.ts | 3 +-
src/scm/localReplicaSCM.ts | 602 +++++++++++++++++---
src/utils/outputChannel.ts | 68 +++
14 files changed, 821 insertions(+), 144 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 src/utils/outputChannel.ts
diff --git a/.vscodeignore b/.vscodeignore
index 84bed7d3..18e7cf0d 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -4,6 +4,7 @@ src/**
patches/**
docs/**
.gitignore
+AGENTS.md
.yarnrc
**/tsconfig.json
**/.eslintrc.json
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..30f56438
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,18 @@
+# Overleaf Workshop Local Fork
+
+## Local Replica Sync
+
+- Runtime local changes are event-driven through VS Code `FileSystemWatcher`; do not add periodic directory or hash polling.
+- `.overleaf/sync-state.json` stores the remote history version and SHA-256 content baseline used only for startup/reconnect reconciliation.
+- Batch startup state changes into one write and skip writes when serialized state is unchanged; do not rewrite the state file once per synchronized path. The file is a disposable cache, so write it in place to avoid delete/create watcher events.
+- History API failures such as HTTP 429 must not be interpreted as a missing version. Leave the checkpoint unchanged and retry on a later reconnect.
+- Reuse recent history updates for both version discovery and changed-path collection. Serialize unavoidable history requests and honor `Retry-After` instead of issuing immediate parallel probes.
+- A failed path must make incremental startup sync fail explicitly; never log completion or advance `remoteVersion` after partial failure.
+- Report final user-actionable failures through a deduplicated VS Code notification with access to the Output log; individual retries remain log-only.
+- Ignore symbolic links and paths below symbolic-link directories in both directions. Never upload them, overwrite/delete them during a pull, or include them in sync state.
+- Preserve the existing conflict policy: when a common history base cannot be obtained, the remote side wins. Do not create conflict-copy files unless the user changes this policy.
+
+## Verification
+
+- Run `npm run compile`, `npm run lint`, and `git diff --check` after synchronization changes.
+- Package local builds with `npx @vscode/vsce package --out overleaf-workshop-local-0.15.10.vsix` and install with `code --install-extension --force`.
diff --git a/docs/anatomy.md b/docs/anatomy.md
index 35a0dda4..a12d5ce7 100644
--- a/docs/anatomy.md
+++ b/docs/anatomy.md
@@ -283,10 +283,9 @@ It also proxies commands for `class HistoryDataProvider` via `(get) triggers`.
#### `src/scm/localReplicaSCM.ts`
The exported `LocalReplicaSCMProvider` implements the `BaseSCM` interface and supports the ["Open Project Locally"](wiki.md#open-project-locally) feature.
-Since the local filesystem keeps no version information, at the start of project open, it always apply `this.overwrite` to overwrite local changes with overleaf server version.
-Therefore, if there is a network disturbance, your local changes or remote changes (by other collaborators) will be lost depending on `syncFromVFS` is called via `this.vfsWatcher` firstly, or `syncToVFS` is called via `this.localWatcher` firstly.
+The provider persists the last synchronized Overleaf project version and SHA-256 hashes in `.overleaf/sync-state.json`. On project open or reconnect, it reuses recent project-history updates to determine both the current version and changed paths. A separate file-tree diff request is only made when those updates do not cover the stored checkpoint. History requests are serialized and rate-limit responses use server-directed backoff. A full remote-to-local synchronization is used when no valid state exists or the history diff is unavailable.
-A smarter solution is proposed in `(private async) overwrite`, but is not applied in the `this.writeFile`.
+If the same text file changed locally and remotely, the existing `diff-match-patch` merge strategy is applied using the previous remote version as the base. If a reliable base cannot be retrieved, the remote version remains authoritative, matching the previous initialization behavior.
#### `src/scm/localGitBridgeSCM.ts`
> Not completed now. Target to provide local git bridge via [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) without local file system or git client binary needed.
diff --git a/docs/wiki.md b/docs/wiki.md
index 1e3df7d3..8399a805 100644
--- a/docs/wiki.md
+++ b/docs/wiki.md
@@ -323,6 +323,10 @@ In the Local Replica configuration, you can choose to enable/disable the Local R

+Changes made to files in the local replica by other applications are also detected and uploaded automatically, including while the VS Code window is in the background. Synchronization is driven by VS Code file-system events and does not require an editor save or window focus. Files matching the Local Replica ignore patterns are excluded. Synchronization diagnostics are available in the `Overleaf Workshop` channel in the Output view.
+
+After the first synchronization, `.overleaf/sync-state.json` records the last Overleaf project version and SHA-256 hashes of synchronized files. Subsequent project opens and reconnects reuse the latest history update response for both the current version and changed paths when possible, so unchanged file contents are not downloaded and an additional file-tree diff request is avoided. History requests are serialized and rate-limit responses honor `Retry-After`. State changes produced by startup reconciliation are batched into one write, unchanged state is not rewritten, and the disposable cache is written in place so it does not repeatedly appear as delete/create events. If synchronization ultimately fails, a deduplicated VS Code error notification links to the `Overleaf Workshop` output log. Local symbolic links and paths below symbolic-link directories are excluded from upload, download, deletion, and sync state.
+
The project-related metadata for local replica are located in `.overleaf/settings.json` in the following format:
```json
{
diff --git a/package.nls.json b/package.nls.json
index 2838547c..0b0e76ea 100644
--- a/package.nls.json
+++ b/package.nls.json
@@ -69,4 +69,4 @@
"views.explorer.overleaf-workshop.chatWebview.contextualTitle": "Overleaf Chat",
"customEditors.overleaf-workshop.pdfViewer.displayName": "Overleaf Workshop PDF Viewer"
-}
\ No newline at end of file
+}
diff --git a/src/api/base.ts b/src/api/base.ts
index 0cc4230d..8142ab07 100644
--- a/src/api/base.ts
+++ b/src/api/base.ts
@@ -1,9 +1,9 @@
/* eslint-disable @typescript-eslint/naming-convention */
-import * as stream from 'stream';
-import * as FormData from 'form-data';
+import { Blob } from 'buffer';
import { v4 as uuidv4 } from 'uuid';
-import { fetch } from 'undici';
+import { fetch, FormData } from 'undici';
import { FileEntity, FileType, FolderEntity, OutputFileEntity } from '../core/remoteFileSystemProvider';
+import { log } from '../utils/outputChannel';
/** Extract set-cookie headers from an undici/Response object. */
function getSetCookie(res: any): string[] {
@@ -177,6 +177,7 @@ export interface ProjectSettingsSchema {
export interface ResponseSchema {
type: 'success' | 'error';
+ statusCode?: number;
raw?: ArrayBuffer;
message?: string;
userInfo?: {userId:string, userEmail:string};
@@ -202,6 +203,44 @@ export interface ResponseSchema {
export class BaseAPI {
private url: string;
private identity?: Identity;
+ private historyRequestChain: Promise = Promise.resolve();
+ private lastHistoryRequestAt = 0;
+
+ private isHistoryRoute(route: string): boolean {
+ return route.includes('/updates?') || route.includes('/filetree/diff?') || route.includes('/diff?');
+ }
+
+ private async waitForHistoryRequest() {
+ const previous = this.historyRequestChain;
+ let release!: () => void;
+ this.historyRequestChain = new Promise(resolve => { release = resolve; });
+ await previous;
+ try {
+ const minimumIntervalMs = 3000;
+ const delayMs = Math.max(0, minimumIntervalMs - (Date.now() - this.lastHistoryRequestAt));
+ if (delayMs>0) {
+ await new Promise(resolve => setTimeout(resolve, delayMs));
+ }
+ this.lastHistoryRequestAt = Date.now();
+ } finally {
+ release();
+ }
+ }
+
+ private retryAfterMs(response: any, attempt: number): number {
+ const retryAfter = response.headers?.get?.('retry-after');
+ if (retryAfter!==undefined && retryAfter!==null) {
+ const seconds = Number(retryAfter);
+ if (Number.isFinite(seconds)) {
+ return Math.max(1000, seconds*1000);
+ }
+ const retryAt = Date.parse(retryAfter);
+ if (Number.isFinite(retryAt)) {
+ return Math.max(1000, retryAt-Date.now());
+ }
+ }
+ return 5000 * Math.pow(2, attempt);
+ }
constructor(url:string) {
this.url = url;
@@ -383,6 +422,9 @@ export class BaseAPI {
for (let attempt = 0; attempt <= MAX_HTTP_RETRIES; attempt++) {
try {
+ if (this.isHistoryRoute(route)) {
+ await this.waitForHistoryRequest();
+ }
let res = undefined;
switch(type) {
case 'GET':
@@ -436,8 +478,8 @@ export class BaseAPI {
} as ResponseSchema;
} else if (res && this.isTransientError(res.status) && attempt < MAX_HTTP_RETRIES) {
// Transient error: retry with backoff
- const delayMs = Math.min(1000 * Math.pow(2, attempt), 4000);
- console.log(`HTTP ${res.status} on ${route}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_HTTP_RETRIES})`);
+ const delayMs = res.status===429 ? this.retryAfterMs(res, attempt) : Math.min(1000 * Math.pow(2, attempt), 4000);
+ log(`HTTP ${res.status} on ${route}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_HTTP_RETRIES})`);
lastError = {statusCode: res.status, message: await res.text().catch(() => '')};
await new Promise(r => setTimeout(r, delayMs));
continue;
@@ -447,6 +489,7 @@ export class BaseAPI {
try { errorBody = await resOrFallback.text(); } catch { errorBody = ''; }
return {
type: 'error',
+ statusCode: typeof resOrFallback.status==='number' ? resOrFallback.status : undefined,
message: `${resOrFallback.status}: ${errorBody}`
};
}
@@ -454,12 +497,13 @@ export class BaseAPI {
const errMsg = err?.message || String(err);
if (this.isTransientError(undefined, errMsg) && attempt < MAX_HTTP_RETRIES) {
const delayMs = Math.min(1000 * Math.pow(2, attempt), 4000);
- console.log(`HTTP fetch error on ${route}: ${errMsg}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_HTTP_RETRIES})`);
+ log(`HTTP fetch error on ${route}: ${errMsg}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_HTTP_RETRIES})`);
await new Promise(r => setTimeout(r, delayMs));
continue;
}
return {
type: 'error',
+ statusCode: undefined,
message: errMsg
};
}
@@ -468,6 +512,7 @@ export class BaseAPI {
// All retries exhausted
return {
type: 'error',
+ statusCode: lastError.statusCode,
message: lastError.message || `Request failed after ${MAX_HTTP_RETRIES + 1} attempts`
};
}
@@ -598,13 +643,14 @@ export class BaseAPI {
}
async uploadFile(identity:Identity, projectId:string, parentFolderId:string, filename:string, fileContent:Uint8Array) {
- const fileStream = stream.Readable.from(fileContent);
const formData = new FormData();
const mimeType = require('mime-types').lookup(filename);
formData.append('targetFolderId', parentFolderId);
formData.append('name', filename);
formData.append('type', mimeType? mimeType : 'text/plain');
- formData.append('qqfile', fileStream, {filename});
+ formData.append('qqfile', new Blob([Buffer.from(fileContent)], {
+ type: mimeType ? mimeType : 'application/octet-stream',
+ }), filename);
this.setIdentity(identity);
return this.request('POST', `project/${projectId}/upload?folder_id=${parentFolderId}`, formData, (res) => {
@@ -616,9 +662,10 @@ export class BaseAPI {
async uploadProject(identity:Identity, filename:string, fileContent:Uint8Array) {
const uuid = uuidv4();
- const fileStream = stream.Readable.from(fileContent);
const formData = new FormData();
- formData.append('qqfile', fileStream, {filename});
+ formData.append('qqfile', new Blob([Buffer.from(fileContent)], {
+ type: 'application/zip',
+ }), filename);
this.setIdentity(identity);
return this.request('POST', `project/new/upload?_csrf=${identity.csrfToken}&qquuid=${uuid}&qqfilename=${filename}&qqtotalfilesize=${fileContent.length}`, formData, (res) => {
diff --git a/src/api/socketio.ts b/src/api/socketio.ts
index 2cfd9e6c..24e39eb4 100644
--- a/src/api/socketio.ts
+++ b/src/api/socketio.ts
@@ -2,6 +2,7 @@
import { Identity, BaseAPI, ProjectMessageResponseSchema } from './base';
import { FileEntity, DocumentEntity, FileRefEntity, FileType, FolderEntity, ProjectEntity } from '../core/remoteFileSystemProvider';
import { EventBus } from '../utils/eventBus';
+import { log, notifyError } from '../utils/outputChannel';
import { SocketIOAlt } from './socketioAlt';
function decodePackedUtf8(text: string): string {
@@ -75,7 +76,7 @@ export interface EventsHandler {
type ConnectionScheme = 'Alt' | 'v1' | 'v2';
export class SocketIOAPI {
- private scheme: ConnectionScheme = 'v1';
+ private scheme: ConnectionScheme;
private record?: Promise;
private _handlers: Array = [];
/** Track EventBus listeners for cleanup to prevent MaxListenersExceededWarning */
@@ -91,6 +92,11 @@ export class SocketIOAPI {
private readonly identity:Identity,
private readonly projectId:string)
{
+ try {
+ this.scheme = new URL(url).hostname==='www.overleaf.com' ? 'v2' : 'v1';
+ } catch {
+ this.scheme = 'v1';
+ }
this.init();
}
@@ -175,19 +181,19 @@ export class SocketIOAPI {
private initInternalHandlers() {
this.socket.on('connect', () => {
- console.log('SocketIOAPI: connected');
+ log('SocketIOAPI: connected');
});
this.socket.on('connect_failed', () => {
- console.log('SocketIOAPI: connect_failed');
+ log('SocketIOAPI: connect_failed');
});
this.socket.on('forceDisconnect', (message:string, delay=10) => {
- console.log('SocketIOAPI: forceDisconnect', message);
+ log('SocketIOAPI: forceDisconnect', message);
});
this.socket.on('connectionRejected', (err:any) => {
- console.log('SocketIOAPI: connectionRejected.', err?.message || err);
+ log('SocketIOAPI: connectionRejected.', err?.message || err);
// If v2 also gets rejected, fall back to v1 rather than staying stuck
if (this.scheme === 'v2') {
- console.log('SocketIOAPI: v2 rejected, falling back to v1');
+ log('SocketIOAPI: v2 rejected, falling back to v1');
this.scheme = 'v1';
}
// Disable auto-reconnect on this socket: the server explicitly rejected
@@ -199,17 +205,21 @@ export class SocketIOAPI {
});
this.socket.on('error', (err:any) => {
// Log error instead of throwing to avoid crashing the extension
- console.error('SocketIOAPI: socket error', err?.message || err);
+ const message = err?.message || String(err);
+ notifyError(`Overleaf connection error: ${message}`, undefined, 'socketio-error');
});
if (this.scheme==='v2') {
- this.record = new Promise(resolve => {
+ this.record = new Promise((resolve, reject) => {
this.socket.on('joinProjectResponse', (res:any) => {
const publicId = res.publicId as string;
const project = res.project as ProjectEntity;
EventBus.fire('socketioConnectedEvent', {publicId});
resolve(project);
});
+ this.socket.on('connectionRejected', (err:any) => {
+ reject(err?.message || err);
+ });
});
}
}
diff --git a/src/api/socketioAlt.ts b/src/api/socketioAlt.ts
index b87fc7d4..38d890a4 100644
--- a/src/api/socketioAlt.ts
+++ b/src/api/socketioAlt.ts
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/naming-convention */
import * as vscode from 'vscode';
+import { error as logError, notifyError } from '../utils/outputChannel';
import * as DiffMatchPatch from 'diff-match-patch';
import { EventEmitter } from 'events';
import { BaseAPI, Identity, ProjectMessageResponseSchema, ProjectSettingsSchema } from './base';
@@ -57,14 +58,21 @@ class SyncTimer {
private _interval: number,
private readonly _callback: () => Promise,
) {
- this._callback().then(() => this.trigger());
+ this.runCallback();
}
- private trigger() {
- this.timer = setTimeout(async () => {
+ private async runCallback() {
+ try {
await this._callback();
+ } catch (error) {
+ notifyError('Overleaf background refresh failed. See the Overleaf Workshop output log.', error, 'alternative-refresh-failed');
+ } finally {
this.trigger();
- }, this._interval);
+ }
+ }
+
+ private trigger() {
+ this.timer = setTimeout(() => this.runCallback(), this._interval);
}
set interval(value: number) {
@@ -146,11 +154,13 @@ export class SocketIOAlt {
private async refreshVFS() {
const vfs = await this.vfs;
const latestVersion = await vfs.getCurrentVersion();
+ if (latestVersion===undefined) { return; }
if (this.vfsLocalVersion===undefined) { this.vfsLocalVersion = latestVersion; }
if (latestVersion===this.vfsLocalVersion) { return; }
const activeUsers = [];
- const diffs = (await vfs.getFileTreeDiff(this.vfsLocalVersion, latestVersion))?.diff;
+ const cachedDiff = vfs.getRecentFileTreeDiff(this.vfsLocalVersion, latestVersion);
+ const diffs = (cachedDiff ?? await vfs.getFileTreeDiff(this.vfsLocalVersion, latestVersion))?.diff;
for (const diff of diffs || []) {
if (diff.operation===undefined) { continue; }
@@ -161,7 +171,7 @@ export class SocketIOAlt {
const {fileType, fileEntity, fileId} = await vfs._resolveUri(vfsUri);
[entityId, entity] = [fileId, fileEntity];
} catch (error) {
- console.error(error);
+ logError(error);
}
// handle vfs update
@@ -339,7 +349,10 @@ export class SocketIOAlt {
}
}
// update local version
- this.vfsLocalVersion = await vfs.getCurrentVersion();
+ const latestVersion = await vfs.getCurrentVersion();
+ if (latestVersion!==undefined) {
+ this.vfsLocalVersion = latestVersion;
+ }
return Promise.resolve();
});
}
diff --git a/src/compile/compileManager.ts b/src/compile/compileManager.ts
index afe2d04e..9086f57a 100644
--- a/src/compile/compileManager.ts
+++ b/src/compile/compileManager.ts
@@ -5,6 +5,7 @@ import { PdfDocument } from '../core/pdfViewEditorProvider';
import { LatexParser, ErrorSchema } from './compileLogParser';
import { EventBus } from '../utils/eventBus';
import { LocalReplicaSCMProvider } from '../scm/localReplicaSCM';
+import { error as logError, warn } from '../utils/outputChannel';
// map string level to severity
const severityMap: Record = {
@@ -297,7 +298,7 @@ export class CompileManager {
const lineIndex = targetLine - 1;
if (lineIndex < 0 || lineIndex >= editor.document.lineCount) {
- console.warn(`${ELEGANT_NAME}: Invalid line number ${targetLine} for revealing in editor. Document has ${editor.document.lineCount} lines.`);
+ warn(`${ELEGANT_NAME}: Invalid line number ${targetLine} for revealing in editor. Document has ${editor.document.lineCount} lines.`);
// Optionally, just focus the editor if the line is invalid
vscode.window.showTextDocument(editor.document, { viewColumn: editor.viewColumn, preserveFocus: false });
return;
@@ -352,13 +353,13 @@ export class CompileManager {
}
},
(error) => {
- console.error(`${ELEGANT_NAME}: Failed to open document ${fileUri.fsPath} for syncPdf:`, error);
+ logError(`${ELEGANT_NAME}: Failed to open document ${fileUri.fsPath} for syncPdf:`, error);
}
);
}
})
.catch(error => {
- console.error(`${ELEGANT_NAME}: Error in syncPdf promise chain:`, error);
+ logError(`${ELEGANT_NAME}: Error in syncPdf promise chain:`, error);
});
}
}
diff --git a/src/core/remoteFileSystemProvider.ts b/src/core/remoteFileSystemProvider.ts
index 0746f732..887ddbc1 100644
--- a/src/core/remoteFileSystemProvider.ts
+++ b/src/core/remoteFileSystemProvider.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import * as vscode from 'vscode';
import * as DiffMatchPatch from 'diff-match-patch';
-import { BaseAPI, MemberEntity, ProjectSettingsSchema } from '../api/base';
+import { BaseAPI, MemberEntity, ProjectFileTreeDiffResponseSchema, ProjectSettingsSchema, ProjectUpdateResponseSchema } from '../api/base';
import { SocketIOAPI, UpdateSchema } from '../api/socketio';
import { OUTPUT_FOLDER_NAME, ROOT_NAME } from '../consts';
import { GlobalStateManager } from '../utils/globalStateManager';
@@ -9,6 +9,7 @@ import { ClientManager } from '../collaboration/clientManager';
import { EventBus } from '../utils/eventBus';
import { SCMCollectionProvider } from '../scm/scmCollectionProvider';
import { ExtendedBaseAPI, ProjectLinkedFileProvider, UrlLinkedFileProvider } from '../api/extendedBase';
+import { error, log, notifyError, warn } from '../utils/outputChannel';
const __OUTPUTS_ID = `${ROOT_NAME}-outputs`;
@@ -109,6 +110,7 @@ export function parseUri(uri: vscode.Uri) {
export class VirtualFileSystem extends vscode.Disposable {
private root?: ProjectEntity;
private currentVersion?: number;
+ private recentUpdates?: ProjectUpdateResponseSchema;
private context: vscode.ExtensionContext;
private api: BaseAPI;
private socket: SocketIOAPI;
@@ -440,11 +442,11 @@ export class VirtualFileSystem extends vscode.Disposable {
this.socket.updateEventHandlers({
onDisconnected: () => {
if (this.root===undefined) { return; } // bypass the first initialization
- console.log("Disconnected");
+ log("Disconnected");
// Debounce: ignore rapid disconnect/reconnect cycles (within 2 seconds)
const now = Date.now();
if (now - this.lastDisconnectTime < 2000) {
- console.log("Disconnected: debounced (too soon since last disconnect)");
+ log("Disconnected: debounced (too soon since last disconnect)");
return;
}
this.lastDisconnectTime = now;
@@ -536,15 +538,20 @@ export class VirtualFileSystem extends vscode.Disposable {
// if doc dirty, local cache should diverge from remote cache
if (_doc && !_doc.isDirty) {doc.localCache = content;}
doc.remoteCache = content;
- this.isDirty = true;
- this.notify([
- {type: vscode.FileChangeType.Changed, uri: this.pathToUri(res.path)}
- ]);
+ } else {
+ // The document has not been opened yet. Invalidate its
+ // lazy cache so the file watcher can fetch it on demand.
+ doc.remoteCache = undefined;
+ doc.localCache = undefined;
}
} else {
doc.remoteCache = undefined;
doc.localCache = undefined;
}
+ this.isDirty = true;
+ this.notify([
+ {type: vscode.FileChangeType.Changed, uri: this.pathToUri(res.path)}
+ ]);
},
onSpellCheckLanguageUpdated: (language:string) => {
if (this.root) {
@@ -660,13 +667,16 @@ export class VirtualFileSystem extends vscode.Disposable {
throw vscode.FileSystemError.FileExists(uri);
}
- let res = undefined;
+ let res: FileEntity | undefined;
+ let failureMessage: string | undefined;
const identity = await GlobalStateManager.authenticate(this.context, this.serverName);
if (content.length===0) {
const _res = await this.api.addDoc(identity, this.projectId, parentFolder._id, fileName);
if (_res.type==='success') {
res = _res.entity;
+ } else {
+ failureMessage = _res.message;
}
} else {
const parentFolderId = parentFolder._id;
@@ -674,9 +684,7 @@ export class VirtualFileSystem extends vscode.Disposable {
if (_res.type==='success' && _res.entity!==undefined) {
res = _res.entity;
} else {
- if (_res.message!==undefined) {
- vscode.window.showErrorMessage(_res.message);
- }
+ failureMessage = _res.message;
}
}
if (res && res._type) {
@@ -684,7 +692,11 @@ export class VirtualFileSystem extends vscode.Disposable {
this.notify([
{type: vscode.FileChangeType.Created, uri: uri},
]);
+ return;
}
+ throw vscode.FileSystemError.Unavailable(
+ failureMessage || vscode.l10n.t('Failed to create {fileName}', {fileName})
+ );
}
async refreshLinkedFile(uri: vscode.Uri) {
@@ -836,6 +848,9 @@ export class VirtualFileSystem extends vscode.Disposable {
if (fileType && fileType==='doc' && fileEntity) {
const doc = fileEntity as DocumentEntity;
const _content = new TextDecoder().decode(content);
+ if (doc.version===undefined || doc.localCache===undefined || doc.remoteCache===undefined) {
+ await this.openFile(uri);
+ }
if (doc.version===undefined || doc.localCache===undefined || doc.remoteCache===undefined) {
return;
}
@@ -989,7 +1004,7 @@ export class VirtualFileSystem extends vscode.Disposable {
if (rootEntry?.path) {
rootResourcePath = rootEntry.path.replace(/^\//, '');
} else {
- console.warn(`Unable to resolve root document id '${resolvedRootDocId}' to a path; compiling without explicit rootResourcePath.`);
+ warn(`Unable to resolve root document id '${resolvedRootDocId}' to a path; compiling without explicit rootResourcePath.`);
}
}
const res = await this.api.compile(identity, this.projectId, rootResourcePath, draft, stopOnFirstError);
@@ -1002,7 +1017,7 @@ export class VirtualFileSystem extends vscode.Disposable {
return true;
} else {
if (res.message!==undefined) {
- console.error('Compile failure.', res.message);
+ error('Compile failure.', res.message);
}
return false;
}
@@ -1210,58 +1225,79 @@ export class VirtualFileSystem extends vscode.Disposable {
const res = await this.api.proxyToHistoryApiAndGetFileDiff(identity, this.projectId, pathname, from, to);
if (res.type==='success') {
return res.diff;
- } else {
+ } else if (res.statusCode===404) {
return undefined;
+ } else {
+ const message = `Failed to fetch file history: ${res.message || 'unknown error'}`;
+ notifyError('Overleaf history request failed. See the Overleaf Workshop output log.', message, `file-history:${res.statusCode || 'unknown'}`);
+ throw new Error(message);
}
}
async getFileTreeDiff(from:number, to:number) {
+ if (from>=to) {
+ return {diff: []};
+ }
const identity = await GlobalStateManager.authenticate(this.context, this.serverName);
const res = await this.api.proxyToHistoryApiAndGetFileTreeDiff(identity, this.projectId, from, to);
if (res.type==='success') {
return res.treeDiff;
- } else {
+ } else if (res.statusCode===404) {
return undefined;
+ } else {
+ const message = `Failed to fetch file tree history: ${res.message || 'unknown error'}`;
+ notifyError('Overleaf history request failed. See the Overleaf Workshop output log.', message, `file-tree-history:${res.statusCode || 'unknown'}`);
+ throw new Error(message);
}
}
async getCurrentVersion() {
- const base = this.currentVersion ?? 0;
- let lb = base;
- let rb = base+2**4;
- // firstly try: a) no update `+1`, b) one update `+2`
- const res = await this.getFileTreeDiff(base+1, base+1);
- if (res===undefined) {
- this.currentVersion = base;
- return base;
+ const identity = await GlobalStateManager.authenticate(this.context, this.serverName);
+ const res = await this.api.proxyToHistoryApiAndGetUpdates(identity, this.projectId);
+ if (res.type!=='success' || res.updates===undefined) {
+ notifyError('Overleaf could not determine the current project version. Startup sync was paused.', res.message, 'current-version-unavailable');
+ return undefined;
}
- const res2 = await this.getFileTreeDiff(base+2, base+2);
- if (res2===undefined) {
- this.currentVersion = base+1;
- return this.currentVersion;
+
+ this.recentUpdates = res.updates;
+ const latestVersion = res.updates.updates.at(0)?.toV ?? 0;
+ if (!Number.isInteger(latestVersion) || latestVersion<0) {
+ notifyError('Overleaf returned an invalid project version. Startup sync was paused.', undefined, 'current-version-invalid');
+ return undefined;
}
- // locate the actual upper bound
- do {
- const res = await this.getFileTreeDiff(rb, rb);
- if (res!==undefined) {
- rb = lb + (rb-lb)*2;
- } else {
- break;
+ this.currentVersion = latestVersion;
+ return this.currentVersion;
+ }
+
+ getRecentFileTreeDiff(from: number, to: number): ProjectFileTreeDiffResponseSchema | undefined {
+ if (from>=to) { return {diff: []}; }
+ const updates = this.recentUpdates?.updates
+ ?.filter(update => update.toV>from && update.fromV<=to)
+ .sort((a, b) => a.fromV-b.fromV);
+ if (updates===undefined || updates.length===0) { return undefined; }
+
+ let coveredUntil = from;
+ for (const update of updates) {
+ if (update.fromV>coveredUntil) { return undefined; }
+ coveredUntil = Math.max(coveredUntil, update.toV);
+ }
+ if (coveredUntil();
+ for (const update of updates) {
+ for (const pathname of update.pathnames || []) {
+ operations.set(pathname, 'edited');
}
- } while (true);
- // binary search the current version
- while (lb ({pathname, operation}))};
}
async createLabel(comment: string, version: number) {
diff --git a/src/extension.ts b/src/extension.ts
index a17b99f2..8028191b 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -7,8 +7,13 @@ import { PdfViewEditorProvider } from './core/pdfViewEditorProvider';
import { CompileManager } from './compile/compileManager';
import { LangIntellisenseProvider } from './intellisense';
import { LocalReplicaSCMProvider } from './scm/localReplicaSCM';
+import { initOutputChannel, log } from './utils/outputChannel';
export function activate(context: vscode.ExtensionContext) {
+ // Keep extension diagnostics in a selectable channel in the Output view.
+ initOutputChannel(context);
+ log('Overleaf Workshop local sync build 2026-08-17.2 activated.');
+
// Register: [core] RemoteFileSystemProvider
const remoteFileSystemProvider = new RemoteFileSystemProvider(context);
context.subscriptions.push( ...remoteFileSystemProvider.triggers );
@@ -51,4 +56,4 @@ export function activate(context: vscode.ExtensionContext) {
export function deactivate() {
vscode.commands.executeCommand('setContext', `${ROOT_NAME}.activate`, false);
vscode.commands.executeCommand('setContext', `${ROOT_NAME}.activateCompile`, false);
-}
\ No newline at end of file
+}
diff --git a/src/intellisense/texDocumentParseUtility.ts b/src/intellisense/texDocumentParseUtility.ts
index 30ced12e..2bfb135a 100644
--- a/src/intellisense/texDocumentParseUtility.ts
+++ b/src/intellisense/texDocumentParseUtility.ts
@@ -1,5 +1,6 @@
import type * as Ast from '@unified-latex/unified-latex-types';
import * as unifiedLaTeXParse from '@unified-latex/unified-latex-util-parse';
+import { error } from '../utils/outputChannel';
// eslint-disable-next-line @typescript-eslint/naming-convention
export enum TeXElementType { Environment, Command, Section, SectionAst, SubFile, BibItem, BibField, BibFile};
@@ -317,7 +318,7 @@ export async function genTexElements(documentText: string): Promise = Promise.resolve();
+ private syncStateBatchDepth = 0;
+ private lastPersistedSyncState?: string;
+ private ignoredLocalSymbolicLinks = new Set();
+ private syncQueue: Promise = Promise.resolve();
private ignorePatterns: string[] = [
'**/.*',
'**/.*/**',
@@ -167,6 +189,169 @@ export class LocalReplicaSCMProvider extends BaseSCM {
return false;
}
+ private normalizeRelPath(path: string): string {
+ const normalized = path.replace(/\\/g, '/');
+ return normalized.startsWith('/') ? normalized : `/${normalized}`;
+ }
+
+ private async statOrUndefined(uri: vscode.Uri): Promise {
+ try {
+ return await vscode.workspace.fs.stat(uri);
+ } catch {
+ return undefined;
+ }
+ }
+
+ private async findLocalSymbolicLink(uri: vscode.Uri): Promise {
+ const basePath = this.baseUri.path.replace(/\/$/, '');
+ if (uri.scheme!==this.baseUri.scheme || uri.authority!==this.baseUri.authority ||
+ (uri.path!==basePath && !uri.path.startsWith(`${basePath}/`))) {
+ return undefined;
+ }
+
+ let currentUri = this.baseUri;
+ let currentPath = '';
+ const parts = uri.path.slice(basePath.length).split('/').filter(Boolean);
+ for (const part of parts) {
+ currentUri = vscode.Uri.joinPath(currentUri, part);
+ currentPath = `${currentPath}/${part}`;
+ const stat = await this.statOrUndefined(currentUri);
+ if (stat!==undefined && (stat.type & vscode.FileType.SymbolicLink)!==0) {
+ return currentPath;
+ }
+ }
+ return undefined;
+ }
+
+ private async ignoreLocalSymbolicLink(uri: vscode.Uri, deleted: boolean = false): Promise {
+ const relPath = this.normalizeRelPath(uri.path.slice(this.baseUri.path.replace(/\/$/, '').length));
+ const symbolicLink = await this.findLocalSymbolicLink(uri);
+ if (symbolicLink!==undefined) {
+ if (!this.ignoredLocalSymbolicLinks.has(symbolicLink)) {
+ warn(`Ignoring local symbolic link "${symbolicLink}"; it will not be synchronized with Overleaf.`);
+ }
+ this.ignoredLocalSymbolicLinks.add(symbolicLink);
+ return true;
+ }
+
+ if (deleted) {
+ const rememberedLink = [...this.ignoredLocalSymbolicLinks]
+ .find(path => relPath===path || relPath.startsWith(`${path}/`));
+ if (rememberedLink!==undefined) {
+ if (relPath===rememberedLink) {
+ this.ignoredLocalSymbolicLinks.delete(rememberedLink);
+ }
+ return true;
+ }
+ } else {
+ // A regular file may intentionally replace an earlier link.
+ this.ignoredLocalSymbolicLinks.delete(relPath);
+ }
+ return false;
+ }
+
+ private async loadSyncState(): Promise {
+ const stateUri = vscode.Uri.joinPath(this.baseUri, SYNC_STATE_PATH);
+ try {
+ const content = await vscode.workspace.fs.readFile(stateUri);
+ const state = JSON.parse(new TextDecoder().decode(content)) as LocalReplicaSyncState;
+ if (state.schemaVersion!==SYNC_STATE_SCHEMA_VERSION ||
+ state.projectUri!==this.vfs.origin.toString() ||
+ !Number.isInteger(state.remoteVersion) ||
+ typeof state.files!=='object' || state.files===null) {
+ return undefined;
+ }
+ this.lastPersistedSyncState = JSON.stringify(state, null, 2);
+ return state;
+ } catch {
+ return undefined;
+ }
+ }
+
+ private persistSyncState(): Promise {
+ if (this.syncState===undefined) { return Promise.resolve(); }
+ const stateUri = vscode.Uri.joinPath(this.baseUri, SYNC_STATE_PATH);
+ const serializedState = JSON.stringify(this.syncState, null, 2);
+ const content = new TextEncoder().encode(serializedState);
+ this.syncStateWritePromise = this.syncStateWritePromise
+ .catch(() => {})
+ .then(async () => {
+ if (serializedState===this.lastPersistedSyncState) { return; }
+ // This is a disposable cache, not user content. Writing in place
+ // avoids delete/create events from the temporary-file rename.
+ await vscode.workspace.fs.writeFile(stateUri, content);
+ this.lastPersistedSyncState = serializedState;
+ });
+ return this.syncStateWritePromise;
+ }
+
+ private scheduleSyncStateWrite() {
+ if (this.syncStateBatchDepth>0) {
+ return;
+ }
+ if (this.syncStateWriteTimer!==undefined) {
+ clearTimeout(this.syncStateWriteTimer);
+ }
+ this.syncStateWriteTimer = setTimeout(() => {
+ this.syncStateWriteTimer = undefined;
+ this.persistSyncState().catch(logError);
+ }, 250);
+ }
+
+ private updateSyncStateFile(relPath: string, content?: Uint8Array) {
+ if (this.syncState===undefined) { return; }
+ const normalizedPath = this.normalizeRelPath(relPath);
+ if (content===undefined) {
+ for (const path of Object.keys(this.syncState.files)) {
+ if (path===normalizedPath || path.startsWith(`${normalizedPath}/`)) {
+ delete this.syncState.files[path];
+ }
+ }
+ } else {
+ this.syncState.files[normalizedPath] = sha256(content);
+ }
+ this.scheduleSyncStateWrite();
+ }
+
+ private async scanLocalFileHashes(): Promise